Files
vdm/core/src/net/content_disposition.cpp
T
samiandClaude Sonnet 5 092e99f7a0 core: meta/veloxpart — resume sidecar, reader first + fuzzed (stage 5)
util/crc32.hpp — header-only CRC-32 (zlib polynomial, reflected), used to
integrity-check the sidecar.

meta/veloxpart — the <name>.veloxpart.meta resume file (docs/04 §5).
Little-endian, versioned, CRC-32 over the whole record. Layout: magic,
version, flags, total_size, downloaded, url set (original/effective/
mirrors), etag/last-modified/content-type, segment records (start, end
INCLUSIVE, completed), optional sha256 streaming-hash blob.

parse_veloxpart() is the attacker-facing surface (the file sits in a
world-writable-ish download dir) and is total on any byte string: CRC
checked before any field is interpreted; magic, a version it understands,
every count and length bounded by a hard cap AND checked against the
remaining buffer; ByteReader latches on overrun; trailing bytes rejected.
Every malformation is meta_corrupt / meta_version_unsupported, never a
crash or an unbounded allocation. serialize_veloxpart() is deterministic
(unchanged sidecar isn't rewritten). File helpers write atomically
(temp + rename) and fdatasync the file and its directory.

Tests: crc32 known vector; full + minimal round-trips; deterministic
serialize; file round-trip; and a truncation/corruption table — bad
magic, CRC mismatch (payload and CRC-field flips), future version,
truncation at every stage, hostile url_count / segment_count / lp_string
length (the case the brief singles out), trailing bytes, impossible
segment.completed.

tools/fuzz/fuzz_veloxpart — feeds raw bytes and bytes-with-valid-CRC
(so the field parser and ByteReader bounds checks are actually reached),
and round-trip-stability-checks anything accepted. Ran 1.1M execs clean
under ASan+UBSan+libFuzzer (clang++-21); fuzz_content_disposition and
fuzz_url likewise re-run to 1.1M. tools/fuzz gains a -runs=0 seed-replay
CTest smoke per target (regression tripwire; the campaign stays manual).

Fuzz-found and fixed: parse_content_disposition could emit a filename
containing NUL / control bytes from a mangled filename* ext-value —
strip_path only removed path separators. Now sanitize_leaf() also drops
C0 controls and DEL (rules/ still owns the authoritative sanitize; `..`
and printable-unsafe content pass through as before).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
2026-09-10 13:52:34 +04:00

293 lines
9.4 KiB
C++

// vdm/net/content_disposition.cpp
#include "vdm/net/content_disposition.hpp"
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
#include "net/text_codec.hpp"
namespace vdm::net {
namespace {
using detail::decode_rfc2047;
using detail::latin1_to_utf8;
using detail::percent_decode;
using detail::to_utf8_best_effort;
std::string_view trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t'))
s.remove_prefix(1);
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n'))
s.remove_suffix(1);
return s;
}
std::string ascii_lower(std::string_view s) {
std::string r(s);
for (char &c : r)
if (c >= 'A' && c <= 'Z')
c = static_cast<char>(c - 'A' + 'a');
return r;
}
// Split "type; a=b; c*=d; e=\"f;g\"" into the type and a param list, honouring quoted
// strings (a ';' inside quotes is not a separator). For quoted values the value stored is
// the *raw* inner text (quotes removed, escapes NOT yet resolved) with `quoted = true`;
// callers resolve escapes and strip path components together (order matters — see
// unquote_strip). Keys are lowercased.
struct Param {
std::string key;
std::string value;
bool quoted = false;
};
struct Params {
std::string type;
std::vector<Param> kv;
[[nodiscard]] const Param *find(std::string_view key) const {
for (const auto &p : kv)
if (p.key == key)
return &p;
return nullptr;
}
};
Params tokenize(std::string_view h) {
Params out;
std::size_t i = 0;
const std::size_t n = h.size();
auto read_segment = [&]() -> std::string_view {
std::size_t start = i;
bool in_q = false;
for (; i < n; ++i) {
char c = h[i];
if (c == '"') {
in_q = !in_q;
} else if (c == '\\' && in_q && i + 1 < n) {
++i; // skip escaped char
} else if (c == ';' && !in_q) {
break;
}
}
std::string_view seg = h.substr(start, i - start);
if (i < n)
++i; // consume ';'
return seg;
};
out.type = ascii_lower(trim(read_segment()));
while (i < n) {
std::string_view seg = trim(read_segment());
if (seg.empty())
continue;
auto eq = seg.find('=');
if (eq == std::string_view::npos) {
out.kv.emplace_back(ascii_lower(seg), std::string{});
continue;
}
std::string key = ascii_lower(trim(seg.substr(0, eq)));
std::string_view rawval = trim(seg.substr(eq + 1));
Param param;
param.key = std::move(key);
if (rawval.size() >= 2 && rawval.front() == '"') {
std::string_view inner = rawval.substr(1);
auto close = inner.rfind('"');
if (close != std::string_view::npos)
inner = inner.substr(0, close);
param.value.assign(inner); // raw, escapes unresolved
param.quoted = true;
} else {
param.value.assign(rawval);
}
out.kv.push_back(std::move(param));
}
return out;
}
// Drop C0 control bytes and DEL, then trim edge whitespace. NUL and control characters
// are never a legitimate part of a filename and are a classic truncation/spoofing vector,
// so the decode layer strips them even though rules/ (stage 9) owns the authoritative
// sanitize. `..` and other "unsafe but printable" content is left for rules/.
std::string sanitize_leaf(std::string s) {
std::string out;
out.reserve(s.size());
for (unsigned char c : s)
if (c >= 0x20 && c != 0x7F)
out.push_back(static_cast<char>(c));
std::string_view v = trim(out);
return std::string(v);
}
std::string strip_path(std::string s) {
auto slash = s.find_last_of("/\\");
if (slash != std::string::npos)
s.erase(0, slash + 1);
return s;
}
// Resolve ONLY the `\"` escape (needed so a quote can appear mid-name). Every other
// backslash is kept literal and later treated as a path separator by strip_path — real
// Windows paths in the wild use `\` unescaped, and path-traversal defence matters more
// than supporting the vanishingly rare filename with a literal backslash.
std::string unescape_dquote(std::string_view raw) {
std::string out;
out.reserve(raw.size());
for (std::size_t i = 0; i < raw.size(); ++i) {
if (raw[i] == '\\' && i + 1 < raw.size() && raw[i + 1] == '"') {
out.push_back('"');
++i;
} else {
out.push_back(raw[i]);
}
}
return out;
}
// Decode an RFC 5987 ext-value: charset'lang'pct-encoded-octets
std::string decode_ext_value(std::string_view v) {
auto q1 = v.find('\'');
if (q1 == std::string_view::npos)
return percent_decode(v); // malformed: best effort
auto q2 = v.find('\'', q1 + 1);
if (q2 == std::string_view::npos)
return percent_decode(v.substr(q1 + 1));
std::string_view charset = v.substr(0, q1);
std::string_view enc = v.substr(q2 + 1);
std::string bytes = percent_decode(enc);
std::string cs = ascii_lower(charset);
if (cs == "iso-8859-1" || cs == "latin1")
return latin1_to_utf8(bytes);
return to_utf8_best_effort(bytes); // utf-8 or unknown -> best effort
}
// Reassemble RFC 2231 continuations: name*0*, name*1, name*2* ... in order.
std::string join_continuations(const Params &p, std::string_view base, bool &is_ext) {
std::vector<std::pair<int, std::string>> parts;
is_ext = false;
for (const auto &param : p.kv) {
const std::string &k = param.key;
const std::string &val = param.value;
if (k.size() <= base.size() + 1 || k.compare(0, base.size(), base) != 0)
continue;
if (k[base.size()] != '*')
continue;
std::string_view rest(k);
rest.remove_prefix(base.size() + 1); // after "base*"
bool star = false;
if (!rest.empty() && rest.back() == '*') {
star = true;
rest.remove_suffix(1);
}
int idx = 0;
for (char c : rest) {
if (c < '0' || c > '9') {
idx = -1;
break;
}
idx = idx * 10 + (c - '0');
}
if (idx < 0)
continue;
if (star)
is_ext = true;
parts.emplace_back(idx, val);
}
if (parts.empty())
return {};
std::sort(parts.begin(), parts.end(),
[](const auto &a, const auto &b) { return a.first < b.first; });
// Piece 0 (if star-form) carries charset'lang' prefix; later pieces are raw
// percent-encoded. Concatenate the percent-encoded text then decode once.
std::string charset_prefix;
std::string enc;
bool first = true;
for (auto &[idx, val] : parts) {
if (first && is_ext) {
auto q1 = val.find('\'');
auto q2 = (q1 == std::string::npos) ? std::string::npos : val.find('\'', q1 + 1);
if (q2 != std::string::npos) {
charset_prefix = val.substr(0, q2 + 1);
enc += val.substr(q2 + 1);
} else {
enc += val;
}
} else {
enc += val;
}
first = false;
}
if (is_ext)
return decode_ext_value(charset_prefix + enc);
return to_utf8_best_effort(percent_decode(enc));
}
ContentDisposition::Type classify(std::string_view t) {
if (t == "inline")
return ContentDisposition::Type::inline_;
if (t == "attachment")
return ContentDisposition::Type::attachment;
if (t == "form-data")
return ContentDisposition::Type::form_data;
if (t.empty())
return ContentDisposition::Type::none;
return ContentDisposition::Type::other;
}
} // namespace
ContentDisposition parse_content_disposition(std::string_view header_value) {
ContentDisposition cd;
header_value = trim(header_value);
if (header_value.empty())
return cd;
Params p = tokenize(header_value);
cd.type = classify(p.type);
// RFC 6266 §4.3: prefer filename* over filename.
std::string ext_name;
bool ext_is_ext = false;
if (const Param *fstar = p.find("filename*")) {
ext_name = decode_ext_value(fstar->value);
ext_is_ext = true;
} else {
std::string joined = join_continuations(p, "filename", ext_is_ext);
if (!joined.empty())
ext_name = std::move(joined);
}
std::string plain_name;
if (const Param *f = p.find("filename")) {
// Resolve `\"`, then decode legacy encoded-words if present — BEFORE stripping
// path components, since a base64 payload can legitimately contain '/'.
std::string raw = f->quoted ? unescape_dquote(f->value) : f->value;
bool had_ew = false;
std::string decoded = decode_rfc2047(raw, &had_ew);
plain_name = had_ew ? std::move(decoded) : to_utf8_best_effort(raw);
}
if (!ext_name.empty()) {
cd.filename = strip_path(std::move(ext_name));
cd.filename_from_ext = ext_is_ext;
} else if (!plain_name.empty()) {
cd.filename = strip_path(std::move(plain_name));
cd.filename_from_ext = false;
}
// Drop control bytes (incl. NUL) and edge whitespace the decoders may have produced.
cd.filename = sanitize_leaf(std::move(cd.filename));
return cd;
}
} // namespace vdm::net