Skip to content

fix(headers): respect quoted strings when splitting header parameters - #196

Merged
ldm0 merged 2 commits into
lexmount:mainfrom
athul-22:fix/header-parameter-quoting
Aug 25, 2026
Merged

fix(headers): respect quoted strings when splitting header parameters#196
ldm0 merged 2 commits into
lexmount:mainfrom
athul-22:fix/header-parameter-quoting

Conversation

@athul-22

Copy link
Copy Markdown
Contributor

Fixes #195. Supersedes #140's sibling PR #194, which fixed the Content-Type case 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 smuggle filename*

Content-Disposition: attachment; filename="a;filename*=UTF-8''evil.exe"

RFC 6266 makes the whole quoted string the filename value; there is no filename* here at all. Moli split on the inner ;, found a filename*, preferred it over the plain name, and saved the download as evil.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 charset

value before after
text/html; boundary="; charset=gbk" gbk (none)
text/html; name="a\"; charset=gbk"; charset=utf-8 gbk utf-8
text/html; charset="utf\-8" utf\-8 → dropped → windows-1252 utf-8

This decides the document's transport encoding, which outranks the meta prescan and the fallback, so the third row is mojibake for any non-ASCII content.

Cache-Control — a quoted field list could leak directives

RFC 9111 lets private and no-cache carry 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-field already exists for exactly this and already backs Content-Disposition parsing in moli-multipart; these three call sites predate it and hand-rolled the split. This adds split_outside_quoted_strings and unquote_parameter_value there and moves all three onto them.

Parsing the plain filename directly also removed the last use of the content_disposition crate, which splits the same way and would have reintroduced the truncation, so that dependency is dropped.

Notes for review

  • 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 rather than swapped for a strict parser. For Content-Type that 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_strings is pinned against plain split for inputs containing no quotes, so the common path is provably unchanged.

Verification

Per AGENTS.md, on aarch64-darwin:

  • cargo fmt --all — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • moli-header-field 13 passed, moli-encoding 56 passed, moli-http-cache 49 passed, moli-protocol downloads suite 29 passed

cargo nextest run --no-fail-fast was still running locally when this was opened; CI covers it here.

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 ldm0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Comment thread moli-header-field/src/parameters.rs Outdated

while index < bytes.len() {
let byte = bytes[index];
if byte == b'"' {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread moli-header-field/src/parameters.rs Outdated
match character {
'"' => return Cow::Owned(unquoted),
'\\' => {
if let Some(escaped) = characters.next() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Suggested change
if let Some(escaped) = characters.next() {
match characters.next() {
Some(escaped) => unquoted.push(escaped),
None => unquoted.push('\\'),
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread moli-encoding/src/labels.rs Outdated
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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@ldm0
ldm0 self-requested a review August 25, 2026 19:35

@ldm0 ldm0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ldm0
ldm0 merged commit eeb2ae6 into lexmount:main Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Quoted strings are ignored when splitting header parameters, letting a quoted value smuggle a filename*, charset, or cache directive

2 participants