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.
|
Hi @athul-22 — thank you again for the careful work in #196. The original filename-smuggling and charset cases were already valuable, and the way you followed up on review—only opening quoted-string mode after If you’re open to it, we’d really like to hear more about the workflow behind those cases: what kind of page, download/export, or agent task led you to them, where Moli helped, and whether any header or encoding behavior still gets in the way. We’re also interested in what you tried before or alongside Moli and what you would most like us to improve next. We can continue entirely here on GitHub in writing. If email or another online channel is easier, that’s fine too. There’s nothing to prepare and no meeting is required; voice or video is completely optional. We’ll make sure anything you share is brought back to the team as product input. If there’s relevant progress we can share later, we’ll do our best to update you here. Even a short reply would help, and no worries at all if now isn’t a good time. |
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.