fix(headers): respect quoted strings when splitting header parameters - #196
Conversation
Three structured header values were parsed by splitting on the separator
and stripping quote characters, with no tracking of quoted strings. A
quoted parameter value was therefore not opaque: a separator inside it
ended the parameter, and the text after it was read as a parameter or
directive of its own.
`Content-Disposition` is the one that matters most:
attachment; filename="a;filename*=UTF-8''evil.exe"
RFC 6266 makes the whole quoted string the `filename`, with no
`filename*` present at all. Splitting on the inner `;` surfaced a
`filename*`, which is preferred over the plain name, so the download was
saved as `evil.exe`. Echoing a user-supplied filename into this header is
common, and quoting the name does not help because the smuggled
parameter travels inside the quotes. The same function already used a
real RFC 6266 parser for the plain filename, so its detection scan and
its extraction disagreed.
`Content-Type` lost or swapped the document's transport encoding:
text/html; boundary="; charset=gbk" read gbk, declares no charset
text/html; charset="utf\-8" read `utf\-8`, an invalid label
The second dropped the charset entirely and fell back to windows-1252,
which is mojibake for non-ASCII content.
`Cache-Control` read a quoted field list's own text as directives, so
`community="x, no-store, y"` stopped a cacheable response being stored.
That one fails safe and is only a lost cache hit, but it is the same
defect.
`moli-header-field` already exists for this job and already backs
`Content-Disposition` parsing in `moli-multipart`; these three call sites
predate it. Add `split_outside_quoted_strings` and
`unquote_parameter_value` there and move all three onto them.
Parsing the plain filename directly also removes the last use of the
`content_disposition` crate, which splits the same way and would have
reintroduced the truncation, so that dependency is dropped.
Byte indices land only on ASCII bytes. A UTF-8 continuation byte can
never equal `"`, `\` or a separator, so scanning stays on character
boundaries; a multibyte case is covered by a test.
Existing tolerances are preserved deliberately. For `Content-Type` that
means whitespace around `=`, apostrophe delimiters, an unterminated
quoted string, uppercase parameter names and a trailing `;`; eighteen
header forms were compared before and after, and only the three defects
above change.
Verified on aarch64-darwin per AGENTS.md: `cargo fmt --all` and
`cargo clippy --workspace --all-targets --all-features -- -D warnings`
are clean; moli-header-field 13, moli-encoding 56, moli-http-cache 49
and the moli-protocol downloads suite 29 all pass.
Closes lexmount#195
ldm0
left a comment
There was a problem hiding this comment.
Thanks for consolidating the quoted-parameter handling. The intended quoted-separator fixes look good, including the Content-Disposition filename-smuggling case, but the shared parsing logic currently introduces three additional regressions:
|
|
||
| while index < bytes.len() { | ||
| let byte = bytes[index]; | ||
| if byte == b'"' { |
There was a problem hiding this comment.
This toggles the quote state for every " in the field, even when the quote is not starting a parameter value after =. As a result, the Content-Type caller no longer recovers from an invalid parameter before a real charset.
For example:
assert_eq!(
charset_from_content_type(r#"text/html;";charset=gbk"#).as_deref(),
Some("gbk"),
);This passes on main and is an explicit WPT MIME case. With this helper, the quote leaves inside_quotes set, so the following semicolon is ignored and the function returns None.
Could the semicolon-separated callers use parameter-aware parsing so quoted-string mode starts only when a parameter value actually begins with "? Please add this case as a regression test.
There was a problem hiding this comment.
Confirmed and fixed in f9d24a4. You are right that the field-wide toggle was wrong: text/html;";charset=gbk returned None here and gbk on main.
Quoted-string mode now begins only at the first non-whitespace character after =, which is the only place a parameter value can start, so a " anywhere else stays ordinary data and the separators behind it keep working. Added your case as header_charset_recovers_after_a_stray_quote, plus a_quote_outside_a_parameter_value_is_ordinary_data at the splitter level and whitespace_between_equals_and_a_quoted_value_still_opens_it to pin the boundary = "; x" form that must still open a quoted value.
| match character { | ||
| '"' => return Cow::Owned(unquoted), | ||
| '\\' => { | ||
| if let Some(escaped) = characters.next() { |
There was a problem hiding this comment.
When \ is the final character, characters.next() returns None and this branch silently drops it. This can turn a malformed value into a valid one. For example, Cache-Control: max-age="31536000\ becomes 31536000, so this PR accepts a one-year freshness lifetime; on main, 31536000\ does not parse.
The Fetch quoted-string algorithm preserves a trailing backslash at EOF. Could we keep it here and add a regression test?
| if let Some(escaped) = characters.next() { | |
| match characters.next() { | |
| Some(escaped) => unquoted.push(escaped), | |
| None => unquoted.push('\\'), | |
| } |
There was a problem hiding this comment.
Fixed in f9d24a4, taking your suggestion. Dropping the trailing backslash turned a malformed value into a well-formed one, which is the wrong direction for a cache lifetime.
Kept as a regression test in two places: a_trailing_backslash_is_kept_rather_than_dropped on the helper, and a_trailing_backslash_does_not_manufacture_a_freshness_lifetime in moli-http-cache, which asserts max-age="31536000\ yields no expiry.
| let charset = unquote_parameter_value(parameter_value.trim()); | ||
| // Apostrophe delimiters are not a quoted string, but receivers have | ||
| // long tolerated them here, so keep stripping them. | ||
| let charset = charset.trim_matches(|ch| ch == '"' || ch == '\'').trim(); |
There was a problem hiding this comment.
unquote_parameter_value has already removed the syntactic delimiters. Any " left in its result can be data produced by an escaped quote, so trimming double quotes here can create a valid encoding label from an invalid value.
For example:
let label =
charset_from_content_type(r#"text/html; charset="utf-8\"""#).unwrap();
assert_eq!(label, "utf-8\"");
assert!(encoding_for_label(&label).is_none());The quoted value is utf-8", but this line trims the final data quote and returns utf-8.
There was a problem hiding this comment.
Agreed, fixed in f9d24a4. The delimiters are gone by that point, so trimming was operating on data and could manufacture a valid label from an invalid one.
The apostrophe tolerance that trim was preserving only ever applied to unquoted values, so it is now applied only to those: a value starting with " goes through unquote_parameter_value untouched, anything else keeps the old trim_matches. Your example is pinned as header_charset_keeps_an_escaped_quote_as_data, asserting the label is utf-8" and that encoding_for_label rejects it.
Review on lexmount#196 found three regressions in the shared helper. The splitter toggled quoted-string mode on every `"`, including one that does not open a parameter value. A stray quote therefore swallowed the rest of the field and hid the parameters behind it, so `text/html;";charset=gbk` stopped resolving to gbk. Quoting now begins only at the first non-whitespace character after `=`, which is where a parameter value can start; a `"` anywhere else is ordinary data. Unquoting dropped a trailing `\` that had nothing to escape, which turned a malformed value into a well-formed one: `max-age="31536000\` became a one-year freshness lifetime. The trailing backslash is now kept, matching the Fetch quoted-string algorithm. The `Content-Type` caller trimmed `"` from the unquoted result, but the delimiters are already gone by then, so any remaining quote is data from an escaped quote. `charset="utf-8\""` is the label `utf-8"`, which is not a valid encoding, and trimming manufactured a valid one. The legacy apostrophe tolerance only ever applied to unquoted values, so it is now applied only to those. Each case has a regression test, including the whitespace-before-quote form that must still open a quoted value.
Fixes #195. Supersedes #140's sibling PR #194, which fixed the
Content-Typecase alone with a local helper; that call site now uses the shared one instead.Three structured header values were parsed by splitting on the separator and stripping quote characters, with no tracking of quoted strings. A quoted parameter value was not opaque, so a separator inside it ended the parameter and the text after it became a parameter or directive of its own.
Content-Disposition— a quoted filename could smugglefilename*RFC 6266 makes the whole quoted string the
filenamevalue; there is nofilename*here at all. Moli split on the inner;, found afilename*, preferred it over the plain name, and saved the download asevil.exe.Echoing a user-supplied filename into this header is common for file hosting, attachments and exports, and quoting the name — the usual advice — does not help, because the smuggled parameter travels inside the quotes. The name the site intended is discarded and the attacker picks the extension.
The same function already used a real RFC 6266 parser for the plain filename, so its detection scan and its extraction disagreed with each other.
Content-Type— a quoted parameter could displace the charsettext/html; boundary="; charset=gbk"gbktext/html; name="a\"; charset=gbk"; charset=utf-8gbkutf-8text/html; charset="utf\-8"utf\-8→ dropped → windows-1252utf-8This decides the document's transport encoding, which outranks the
metaprescan and the fallback, so the third row is mojibake for any non-ASCII content.Cache-Control— a quoted field list could leak directivesRFC 9111 lets
privateandno-cachecarry a quoted field list containing commas.max-age=600, community="x, no-store, y"had the argument's own text read as directives, so a cacheable response was not stored. This one fails safe — the failure mode is a lost cache hit, not caching something it should not — but it is the same defect and is fixed alongside.Approach
moli-header-fieldalready exists for exactly this and already backsContent-Dispositionparsing inmoli-multipart; these three call sites predate it and hand-rolled the split. This addssplit_outside_quoted_stringsandunquote_parameter_valuethere and moves all three onto them.Parsing the plain filename directly also removed the last use of the
content_dispositioncrate, which splits the same way and would have reintroduced the truncation, so that dependency is dropped.Notes for review
",\or a separator, so scanning stays on character boundaries. A multibyte case is covered by a test.Content-Typethat means whitespace around=, apostrophe delimiters, an unterminated quoted string, uppercase parameter names and a trailing;. I compared eighteen header forms before and after; only the three defects above change.split_outside_quoted_stringsis pinned against plainsplitfor inputs containing no quotes, so the common path is provably unchanged.Verification
Per AGENTS.md, on aarch64-darwin:
cargo fmt --all— cleancargo clippy --workspace --all-targets --all-features -- -D warnings— cleanmoli-header-field13 passed,moli-encoding56 passed,moli-http-cache49 passed,moli-protocoldownloads suite 29 passedcargo nextest run --no-fail-fastwas still running locally when this was opened; CI covers it here.