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
166 lines
6.3 KiB
C++
166 lines
6.3 KiB
C++
#include "vdm/net/content_disposition.hpp"
|
|
|
|
#include <string>
|
|
#include <string_view>
|
|
|
|
#include "vtest.hpp"
|
|
|
|
using vdm::net::ContentDisposition;
|
|
using vdm::net::parse_content_disposition;
|
|
using Type = vdm::net::ContentDisposition::Type;
|
|
|
|
namespace {
|
|
// "€" is U+20AC -> UTF-8 E2 82 AC. "£" is U+00A3 -> UTF-8 C2 A3.
|
|
const std::string kEuro = "\xE2\x82\xAC";
|
|
const std::string kPound = "\xC2\xA3";
|
|
const std::string kEAcute = "\xC3\xA9"; // é U+00E9
|
|
} // namespace
|
|
|
|
VT_TEST(cd_plain_quoted) {
|
|
auto cd = parse_content_disposition(R"(attachment; filename="report.pdf")");
|
|
VT_CHECK(cd.type == Type::attachment);
|
|
VT_CHECK_EQ(cd.filename, std::string("report.pdf"));
|
|
VT_CHECK(!cd.filename_from_ext);
|
|
}
|
|
|
|
VT_TEST(cd_plain_token_unquoted) {
|
|
auto cd = parse_content_disposition("attachment; filename=report.pdf");
|
|
VT_CHECK_EQ(cd.filename, std::string("report.pdf"));
|
|
}
|
|
|
|
VT_TEST(cd_inline_no_filename) {
|
|
auto cd = parse_content_disposition("inline");
|
|
VT_CHECK(cd.type == Type::inline_);
|
|
VT_CHECK(!cd.has_filename());
|
|
}
|
|
|
|
VT_TEST(cd_rfc5987_utf8_ext_value) {
|
|
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%e2%82%ac%20rates.pdf");
|
|
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
|
|
VT_CHECK(cd.filename_from_ext);
|
|
}
|
|
|
|
VT_TEST(cd_prefers_ext_over_plain) {
|
|
auto cd = parse_content_disposition(
|
|
R"(attachment; filename="EURO rates.pdf"; filename*=UTF-8''%e2%82%ac%20rates.pdf)");
|
|
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
|
|
VT_CHECK(cd.filename_from_ext);
|
|
}
|
|
|
|
VT_TEST(cd_rfc5987_latin1_ext_value) {
|
|
// %A3 = £ in ISO-8859-1
|
|
auto cd = parse_content_disposition("attachment; filename*=ISO-8859-1''%A3rates.pdf");
|
|
VT_CHECK_EQ(cd.filename, kPound + "rates.pdf");
|
|
}
|
|
|
|
VT_TEST(cd_legacy_rfc2047_base64) {
|
|
// base64("<euro> rates.pdf") with euro as UTF-8
|
|
// "€ rates.pdf" -> bytes E2 82 AC 20 72 61 74 65 73 2E 70 64 66 -> base64:
|
|
auto cd =
|
|
parse_content_disposition(R"(attachment; filename="=?UTF-8?B?4oKsIHJhdGVzLnBkZg==?=")");
|
|
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
|
|
}
|
|
|
|
VT_TEST(cd_legacy_rfc2047_qencoded_latin1) {
|
|
// =?ISO-8859-1?Q?=A3rates.pdf?= -> £rates.pdf
|
|
auto cd = parse_content_disposition(R"(attachment; filename="=?ISO-8859-1?Q?=A3rates.pdf?=")");
|
|
VT_CHECK_EQ(cd.filename, kPound + "rates.pdf");
|
|
}
|
|
|
|
VT_TEST(cd_raw_utf8_bytes_in_quotes) {
|
|
std::string h = "attachment; filename=\"caf" + kEAcute + ".txt\"";
|
|
auto cd = parse_content_disposition(h);
|
|
VT_CHECK_EQ(cd.filename, "caf" + kEAcute + ".txt");
|
|
}
|
|
|
|
VT_TEST(cd_raw_latin1_byte_in_quotes) {
|
|
// 0xE9 is 'é' in Latin-1; not valid UTF-8 alone -> transcoded
|
|
std::string h = "attachment; filename=\"caf\xE9.txt\"";
|
|
auto cd = parse_content_disposition(h);
|
|
VT_CHECK_EQ(cd.filename, "caf" + kEAcute + ".txt");
|
|
}
|
|
|
|
VT_TEST(cd_strips_path_components) {
|
|
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="../../etc/passwd")").filename,
|
|
std::string("passwd"));
|
|
// real Windows paths in the wild use unescaped backslashes as separators
|
|
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="C:\Windows\evil.exe")").filename,
|
|
std::string("evil.exe"));
|
|
// a base64 payload can contain '/', so decode must happen before path stripping
|
|
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="=?UTF-8?B?Li4vLi4vc2VjcmV0?=")")
|
|
.filename,
|
|
std::string("secret")); // decodes to "../../secret", then stripped
|
|
}
|
|
|
|
VT_TEST(cd_quoted_dquote_escape) {
|
|
// \" is the one escape we resolve, so a quote can appear mid-name
|
|
auto cd = parse_content_disposition(R"(attachment; filename="quote\"here.txt")");
|
|
VT_CHECK_EQ(cd.filename, std::string("quote\"here.txt"));
|
|
}
|
|
|
|
VT_TEST(cd_semicolon_inside_quotes_is_not_a_separator) {
|
|
auto cd = parse_content_disposition(R"(attachment; filename="a;b;c.txt")");
|
|
VT_CHECK_EQ(cd.filename, std::string("a;b;c.txt"));
|
|
}
|
|
|
|
VT_TEST(cd_form_data) {
|
|
auto cd = parse_content_disposition(R"(form-data; name="file"; filename="upload.bin")");
|
|
VT_CHECK(cd.type == Type::form_data);
|
|
VT_CHECK_EQ(cd.filename, std::string("upload.bin"));
|
|
}
|
|
|
|
VT_TEST(cd_rfc2231_continuations) {
|
|
auto cd = parse_content_disposition(
|
|
"attachment; filename*0*=UTF-8''%e2%82%ac; filename*1*=%20rates; filename*2=.pdf");
|
|
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
|
|
}
|
|
|
|
VT_TEST(cd_empty_and_garbage_do_not_crash) {
|
|
VT_CHECK(!parse_content_disposition("").has_filename());
|
|
VT_CHECK(!parse_content_disposition(";;;;").has_filename());
|
|
VT_CHECK(!parse_content_disposition("attachment;").has_filename());
|
|
VT_CHECK(!parse_content_disposition(R"(attachment; filename=)").has_filename());
|
|
VT_CHECK(!parse_content_disposition(R"(attachment; filename="")").has_filename());
|
|
// truncated ext-value
|
|
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%e2%82");
|
|
VT_CHECK(cd.type == Type::attachment); // no crash; filename is whatever fell out
|
|
// truncated encoded-word
|
|
auto trunc = parse_content_disposition(R"(attachment; filename="=?UTF-8?B?4oKs")");
|
|
(void)trunc;
|
|
}
|
|
|
|
VT_TEST(cd_bad_percent_escapes_in_ext_value) {
|
|
// stray % and non-hex digits are emitted literally, no crash
|
|
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%ZZ%%file%2");
|
|
VT_CHECK(cd.type == Type::attachment);
|
|
}
|
|
|
|
VT_TEST(cd_strips_control_bytes_and_nul) {
|
|
// A mangled ext-value that decodes to bytes with embedded NULs (fuzz-found).
|
|
std::string h1("attachment; filename*=x''%e2%82%a");
|
|
h1.push_back('\0');
|
|
h1.push_back('\0');
|
|
h1 += "ff.pdf";
|
|
auto cd = parse_content_disposition(h1);
|
|
for (unsigned char c : cd.filename)
|
|
VT_CHECK(c >= 0x20 && c != 0x7F);
|
|
// a plain filename with a tab / newline / SOH loses them
|
|
std::string h2("attachment; filename=\"a\tb\nc");
|
|
h2.push_back('\x01');
|
|
h2 += ".txt\"";
|
|
auto cd2 = parse_content_disposition(h2);
|
|
VT_CHECK_EQ(cd2.filename, std::string("abc.txt"));
|
|
}
|
|
|
|
VT_TEST(cd_case_insensitive_keys_and_type) {
|
|
auto cd = parse_content_disposition(R"(ATTACHMENT; FileName="x.txt")");
|
|
VT_CHECK(cd.type == Type::attachment);
|
|
VT_CHECK_EQ(cd.filename, std::string("x.txt"));
|
|
}
|
|
|
|
VT_TEST(cd_unknown_type_is_other) {
|
|
auto cd = parse_content_disposition(R"(signal; filename="x.txt")");
|
|
VT_CHECK(cd.type == Type::other);
|
|
VT_CHECK_EQ(cd.filename, std::string("x.txt"));
|
|
}
|