python-multipart: Content-Disposition parameter smuggling via RFC 2231/5987 extended parameters
Description
python-multipart's parse_options_header applies RFC 2231/5987 decoding, allowing an attacker to smuggle different field names or filenames in multipart/form-data past upstream inspectors.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
python-multipart's `parse_options_header` applies RFC 2231/5987 decoding, allowing an attacker to smuggle different field names or filenames in multipart/form-data past upstream inspectors.
Vulnerability
parse_options_header in python-multipart uses email.message.Message to parse Content-Disposition and Content-Type headers, which transparently decodes RFC 2231/5987 extended parameter syntax (filename*=..., name*=..., and continuation forms like filename*0/filename*1). The decoded extended value overrides a plain filename= or name= parameter if both are present. This conflicts with RFC 7578 §4.2, which forbids the filename* form in multipart/form-data. The bug affects the high-level parse_options_header, FormParser, create_form_parser, and parse_form APIs, and reaches Starlette/FastAPI through request.form() [1][2].
Exploitation
An attacker submits a multipart/form-data request containing both a plain and an extended parameter. For example, Content-Disposition: form-data; name="comment"; name*=utf-8''role causes an inspector following RFC 7578 to see field comment, while python-multipart returns name=role. A filename example is Content-Disposition: form-data; name="upload"; filename="safe.txt"; filename*=utf-8''evil.php, where the inspector sees safe.txt but the returned filename is evil.php. Continuation parameters and percent-encoding are also decoded (e.g., ..%2F, %00) [1][2]. No authentication is required; the attacker simply sends a crafted HTTP request.
Impact
This interpretation conflict (CWE-436) lets an attacker smuggle a different field name or filename past upstream body-inspecting components (such as WAFs, proxies, or gateways) to the backend application. The concrete impact depends on how the upstream component uses the field name or filename; it could enable bypassing file extension filters, field name validation, or upload path restrictions [1][2].
Mitigation
The fixed version is not yet disclosed in the available references [1][2][3]. Users should monitor the python-multipart repository for a patched release. As a workaround, applications can avoid relying on request.form() or parse_options_header for security decisions, and instead use a separate parser that conforms to RFC 7578.
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2- Range: < 0.0.30
Patches
13506c15ce99cIgnore RFC 2231 extended parameters in `parse_options_header` (#291)
3 files changed · +87 −38
CHANGELOG.md+1 −0 modified@@ -3,6 +3,7 @@ ## Unreleased * Parse `application/x-www-form-urlencoded` bodies per the WHATWG URL standard, treating only `&` as a field separator. +* Ignore RFC 2231/5987 extended parameters (`name*`, `filename*`) in `parse_options_header`, keeping the plain parameter authoritative per [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2). ## 0.0.29 (2026-05-17)
python_multipart/multipart.py+46 −33 modified@@ -5,7 +5,6 @@ import shutil import sys import tempfile -from email.message import Message from enum import IntEnum from io import BufferedRandom, BytesIO from numbers import Number @@ -156,10 +155,37 @@ class MultipartState(IntEnum): """ +def _parseparam(s: str) -> list[str]: + # Vendored from the standard library's + # [`email.message._parseparam`](https://github.com/python/cpython/blob/v3.14.2/Lib/email/message.py#L73-L96) + # to split a header into its `;`-separated parts without treating a `;` inside a double-quoted string as a + # separator - and without the RFC 2231 decoding that `email.message.Message.get_params` would apply on top. + s = ";" + s + plist: list[str] = [] + start = 0 + while s.find(";", start) == start: + start += 1 + end = s.find(";", start) + ind, diff = start, 0 + while end > 0: + diff += s.count('"', ind, end) - s.count('\\"', ind, end) + if diff % 2 == 0: + break + end, ind = ind, s.find(";", end + 1) + if end < 0: + end = len(s) + i = s.find("=", start, end) + if i == -1: + f = s[start:end] + else: + f = s[start:i].rstrip().lower() + "=" + s[i + 1 : end].lstrip() + plist.append(f.strip()) + start = end + return plist + + def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, bytes]]: """Parses a Content-Type header into a value in the following format: (content_type, {parameters}).""" - # Uses email.message.Message to parse the header as described in PEP 594. - # Ref: https://peps.python.org/pep-0594/#cgi if not value: return (b"", {}) @@ -174,37 +200,24 @@ def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, if ";" not in value: return (value.lower().strip().encode("latin-1"), {}) - # Split at the first semicolon, to get our value and then options. - # ctype, rest = value.split(b';', 1) - message = Message() - message["content-type"] = value - # `get_params()` can raise on malformed RFC 2231 headers found via fuzzing: - # - ValueError on oversized continuation indices (all supported versions). - # - TypeError on mixed `filename*` + `filename*0*` continuations (Python 3.12 only; - # 3.13+ silently picks a value). - # TODO: drop `TypeError` once Python 3.12 reaches EOL (October 2028). - try: - params = message.get_params() - except (TypeError, ValueError): # pragma: no cover - return (value.split(";", 1)[0].lower().strip().encode("latin-1"), {}) - # If there were no parameters, this would have already returned above - assert params, "At least the content type value should be present" - ctype = params.pop(0)[0].encode("latin-1") + ctype, *segments = _parseparam(value) options: dict[bytes, bytes] = {} - for param in params: - key, value = param - # If the value returned from get_params() is a 3-tuple, the last - # element corresponds to the value. - # See: https://docs.python.org/3/library/email.compat32-message.html - if isinstance(value, tuple): - value = value[-1] - # If the value is a filename, we need to fix a bug on IE6 that sends - # the full file path instead of the filename. - if key == "filename": - if value[1:3] == ":\\" or value[:2] == "\\\\": - value = value.split("\\")[-1] - options[key.encode("latin-1")] = value.encode("latin-1") - return ctype, options + for segment in segments: + key, _, val = segment.partition("=") + # [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2) + # forbids the RFC 5987/2231 extended syntax (`key*=`, `key*0`, ...) in + # multipart/form-data, so we ignore those parameters and keep the plain + # `key` authoritative. + if "*" in key: + continue + if len(val) >= 2 and val[0] == '"' and val[-1] == '"': + val = val[1:-1].replace("\\\\", "\\").replace('\\"', '"') + # Work around an IE6 bug where the full file path is sent instead of + # just the filename. + if key == "filename" and (val[1:3] == ":\\" or val[:2] == "\\\\"): + val = val.split("\\")[-1] + options[key.encode("latin-1")] = val.encode("latin-1") + return ctype.encode("latin-1"), options class Field:
tests/test_multipart.py+40 −5 modified@@ -299,24 +299,59 @@ def test_redos_attack_header(self) -> None: # If vulnerable, this test wouldn't finish, the line above would hang self.assertIn(b'"\\', p[b"!"]) - def test_handles_rfc_2231(self) -> None: + def test_ignores_rfc_2231_extended_param(self) -> None: + # RFC 7578 §4.2 forbids the RFC 5987/2231 extended syntax, so the + # decoded `param*` value is not exposed under `param`. t, p = parse_options_header(b"text/plain; param*=us-ascii'en-us'encoded%20message") - self.assertEqual(p[b"param"], b"encoded message") + self.assertEqual(t, b"text/plain") + self.assertEqual(p, {}) + + def test_plain_param_authoritative_over_extended(self) -> None: + # When both plain and extended forms are present, the plain one wins + # and the extended one is ignored. + _, p = parse_options_header(b"form-data; name=\"comment\"; name*=utf-8''other") + + self.assertEqual(p, {b"name": b"comment"}) - def test_rejects_oversized_rfc_2231_index(self) -> None: + def test_ignores_rfc_2231_continuation_filename(self) -> None: + _, p = parse_options_header(b'form-data; name="f"; filename*0="a"; filename*1="b.txt"') + + self.assertEqual(p, {b"name": b"f"}) + + def test_ignores_oversized_rfc_2231_index(self) -> None: t, p = parse_options_header("text/plain; filename*" + ("1" * 4301) + "*=utf-8''x") self.assertEqual(t, b"text/plain") self.assertEqual(p, {}) - @pytest.mark.skipif(sys.version_info >= (3, 13), reason="email parser only raises TypeError on Python 3.12") - def test_rejects_mixed_rfc_2231_continuations(self) -> None: + def test_ignores_mixed_rfc_2231_continuations(self) -> None: t, p = parse_options_header("text/plain; filename*=utf-8''a; filename*0*=utf-8''b") self.assertEqual(t, b"text/plain") self.assertEqual(p, {}) + def test_ignores_extended_param_case_insensitively(self) -> None: + _, p = parse_options_header(b"text/plain; UPPER*=utf-8''X") + + self.assertEqual(p, {}) + + def test_preserves_quoted_semicolons_and_escapes(self) -> None: + _, p = parse_options_header(b'text/plain; a="x;y"; b="esc \\" quote"') + + self.assertEqual(p, {b"a": b"x;y", b"b": b'esc " quote'}) + + def test_preserves_content_type_case(self) -> None: + t, p = parse_options_header(b"Text/Plain; a=b") + + self.assertEqual(t, b"Text/Plain") + self.assertEqual(p, {b"a": b"b"}) + + def test_preserves_backslash_unquoting_order(self) -> None: + _, p = parse_options_header(b'text/plain; q="a\\\\b"') + + self.assertEqual(p, {b"q": b"a\\b"}) + class TestBaseParser(unittest.TestCase): def setUp(self) -> None:
Vulnerability mechanics
Root cause
"The `parse_options_header` function used `email.message.Message` which transparently applies RFC 2231/5987 decoding, allowing extended parameters (`name*`, `filename*`) to override plain parameters in violation of RFC 7578 §4.2."
Attack vector
An attacker submits a `multipart/form-data` request containing both a plain parameter (e.g., `filename="safe.txt"`) and an extended parameter (e.g., `filename*=utf-8''evil.php`). An upstream inspector that follows RFC 7578 §4.2 (which forbids the extended syntax) sees only the plain value, while the vulnerable `parse_options_header` returns the extended value. This interpretation conflict [CWE-436] allows smuggling a different field name or filename past the inspector to the backend. The same technique works with continuation parameters (`filename*0`, `filename*1`, etc.) and percent-decoded sequences like `..%2F` or `%00`.
Affected code
The vulnerability resides in `python_multipart/multipart.py` in the `parse_options_header` function, which previously used `email.message.Message` to parse `Content-Disposition` and `Content-Type` headers. That approach transparently applied RFC 2231/5987 decoding, causing extended parameters like `filename*` and `name*` to override the plain `filename`/`name` keys. The patch replaces the `email.message.Message`-based parsing with a vendored `_parseparam` function that splits on `;` without applying RFC 2231 decoding, and explicitly skips any parameter key containing `*`.
What the fix does
The patch replaces the `email.message.Message`-based header parsing with a vendored `_parseparam` function that splits the header on `;` without applying RFC 2231/5987 decoding. Any parameter key containing `*` is now skipped entirely, so the plain `name`/`filename` parameter remains authoritative. This aligns the parser with RFC 7578 §4.2, which forbids the extended syntax in `multipart/form-data`. The change also removes the previous fallback that returned only the content type on malformed headers, and adds proper handling of quoted semicolons and escape sequences.
Preconditions
- configThe application must use the vulnerable `parse_options_header`, `FormParser`, `create_form_parser`, or `parse_form` APIs, or reach them through Starlette/FastAPI `request.form()`.
- inputThe attacker must be able to submit a `multipart/form-data` request with crafted `Content-Disposition` headers.
- networkAn upstream inspector (WAF, proxy, gateway) must follow RFC 7578 and not apply RFC 2231/5987 decoding, creating the interpretation conflict.
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.