VYPR
Low severity3.7GHSA Advisory· Published Jun 15, 2026· Updated Jun 15, 2026

python-multipart: Content-Disposition parameter smuggling via RFC 2231/5987 extended parameters

CVE-2026-53537

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

Patches

1
3506c15ce99c

Ignore RFC 2231 extended parameters in `parse_options_header` (#291)

https://github.com/Kludex/python-multipartMarcelo TrylesinskiMay 31, 2026Fixed in 0.0.30via llm-release-walk
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

2

News mentions

0

No linked articles in our index yet.