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

python-multipart: Semicolon treated as querystring field separator enables parameter smuggling

CVE-2026-53538

Description

python-multipart's QuerystringParser treats ; as a field separator, conflicting with WHATWG standard, enabling HTTP parameter smuggling via parser differential.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

python-multipart's QuerystringParser treats `;` as a field separator, conflicting with WHATWG standard, enabling HTTP parameter smuggling via parser differential.

Vulnerability

The QuerystringParser in python-multipart versions prior to 0.0.30 [1] accepts ; as a field separator in application/x-www-form-urlencoded bodies, in addition to the standard &. The relevant code in multipart.py scans for & first and falls back to ; [1][2]. This contradicts the WHATWG URL standard and the behavior of modern browsers and Python's urllib.parse (since CVE-2021-23336 fix), which only recognize & as a separator [1][2]. The parser is reachable via the public QuerystringParser class, FormParser, create_form_parser, parse_form APIs, and through Starlette/FastAPI request.form() for URL-encoded payloads [1][2].

Exploitation

An attacker sends a crafted request body where a ; separates hidden fields after an apparent benign payload. For example, role=user&x=;role=admin is parsed by a WHATWG-compliant upstream (WAF or gateway) as two fields: role=user and x=";role=admin", passing inspection [1][2]. The QuerystringParser tokenizes the same bytes into three fields: role="user", x="", and role="admin" [1][2]. The final value of role (admin) overwrites the earlier value in applications like Starlette/FastAPI where last value wins [1][2]. No special network position beyond the ability to send POST/PUT requests with application/x-www-form-urlencoded bodies is required; no prior authentication is needed [1][2].

Impact

Successful exploitation allows HTTP parameter pollution/smuggling: an attacker can inject or override form field values that bypass upstream security controls, reaching the backend with parameters the intermediary never validated [1][2]. The scope is limited to URL-encoded body parsing differentials, potentially enabling unauthorized access or privilege escalation if the upstream relies on parameter inspection for authorization decisions (e.g., role escalation) [1][2].

Mitigation

Upgrade to python-multipart version 0.0.30 or later, released in June 2026, which treats & as the sole field separator in URL-encoded bodies [1][2]. No workaround is documented; the fix changes the parsing behavior to match the WHATWG standard [1][2].

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
d69df35cd2ca

Treat only `&` as the urlencoded field separator (#290)

https://github.com/Kludex/python-multipartMarcelo TrylesinskiMay 31, 2026Fixed in 0.0.30via llm-release-walk
3 files changed · +14 16
  • CHANGELOG.md+4 0 modified
    @@ -1,5 +1,9 @@
     # Changelog
     
    +## Unreleased
    +
    +* Parse `application/x-www-form-urlencoded` bodies per the WHATWG URL standard, treating only `&` as a field separator.
    +
     ## 0.0.29 (2026-05-17)
     
     * Handle malformed RFC 2231 continuations in `parse_options_header` [#270](https://github.com/Kludex/python-multipart/pull/270).
    
  • python_multipart/multipart.py+7 13 modified
    @@ -126,7 +126,6 @@ class MultipartState(IntEnum):
     SPACE = b" "[0]
     HYPHEN = b"-"[0]
     AMPERSAND = b"&"[0]
    -SEMICOLON = b";"[0]
     LOWER_A = b"a"[0]
     LOWER_Z = b"z"[0]
     NULL = b"\x00"[0]
    @@ -840,13 +839,13 @@ def _internal_write(self, data: bytes, length: int) -> int:
                     # yet reached a separator, and thus, if we do, we need to skip
                     # it as it will be the boundary between fields that's supposed
                     # to be there.
    -                if ch == AMPERSAND or ch == SEMICOLON:
    +                if ch == AMPERSAND:
                         if found_sep:
                             # If we're parsing strictly, we disallow blank chunks.
                             if strict_parsing:
    -                            raise QuerystringParseError("Skipping duplicate ampersand/semicolon at %d" % i, offset=i)
    +                            raise QuerystringParseError("Skipping duplicate ampersand at %d" % i, offset=i)
                             else:
    -                            self.logger.debug("Skipping duplicate ampersand/semicolon at %d", i)
    +                            self.logger.debug("Skipping duplicate ampersand at %d", i)
                         else:
                             # This case is when we're skipping the (first)
                             # separator between fields, so we just set our flag
    @@ -864,17 +863,15 @@ def _internal_write(self, data: bytes, length: int) -> int:
                 elif state == QuerystringState.FIELD_NAME:
                     # Try and find a separator - we ensure that, if we do, we only
                     # look for the equal sign before it.
    -                sep_pos = data.find(b"&", i)
    -                if sep_pos == -1:
    -                    sep_pos = data.find(b";", i)
    +                sep_pos = data.find(b"&", i, length)
     
                     # See if we can find an equals sign in the remaining data.  If
                     # so, we can immediately emit the field name and jump to the
                     # data state.
                     if sep_pos != -1:
                         equals_pos = data.find(b"=", i, sep_pos)
                     else:
    -                    equals_pos = data.find(b"=", i)
    +                    equals_pos = data.find(b"=", i, length)
     
                     if equals_pos != -1:
                         # Emit this name.
    @@ -921,11 +918,8 @@ def _internal_write(self, data: bytes, length: int) -> int:
                             i = length
     
                 elif state == QuerystringState.FIELD_DATA:
    -                # Try finding either an ampersand or a semicolon after this
    -                # position.
    -                sep_pos = data.find(b"&", i)
    -                if sep_pos == -1:
    -                    sep_pos = data.find(b";", i)
    +                # Try finding an ampersand after this position.
    +                sep_pos = data.find(b"&", i, length)
     
                     # If we found it, callback this bit as data and then go back
                     # to expecting to find a field.
    
  • tests/test_multipart.py+3 3 modified
    @@ -428,10 +428,10 @@ def test_streaming_break(self) -> None:
             self.p.write(b"f=baz")
             self.assert_fields((b"asdf", b"baz"))
     
    -    def test_semicolon_separator(self) -> None:
    -        self.p.write(b"foo=bar;asdf=baz")
    +    def test_semicolon_is_data_not_a_field_separator(self) -> None:
    +        self.p.write(b"role=user&x=;role=admin")
     
    -        self.assert_fields((b"foo", b"bar"), (b"asdf", b"baz"))
    +        self.assert_fields((b"role", b"user"), (b"x", b";role=admin"))
     
         def test_too_large_field(self) -> None:
             self.p.max_size = 15
    

Vulnerability mechanics

Root cause

"`QuerystringParser` treated `;` as a field separator in addition to `&`, creating a parser differential with WHATWG-compliant intermediaries."

Attack vector

An attacker sends an `application/x-www-form-urlencoded` body containing `;` as a field boundary, e.g. `role=user&x=;role=admin`. A WHATWG-compliant upstream intermediary (WAF/gateway) parses only `&` as a separator, seeing two fields (`role=user` and `x=";role=admin"`) and forwarding the request. `QuerystringParser` treats `;` as a separator, producing three fields (`role=user`, `x=`, `role=admin`), and the backend (e.g. Starlette/FastAPI `request.form()`, where the last value wins) receives `role=admin` — a value the upstream validator never inspected [CWE-436] [ref_id=1].

Affected code

The vulnerability is in `python_multipart/multipart.py` in the `_internal_write` method of `QuerystringParser`. The `FIELD_NAME` and `FIELD_DATA` states scanned for `&` and, failing that, for `;` as a field separator. The patch removes the `;` fallback, treating only `&` as a separator per the WHATWG URL standard [patch_id=6110810].

What the fix does

The patch removes the `SEMICOLON` constant and all `;`-scanning logic from `_internal_write`. In both `FIELD_NAME` and `FIELD_DATA` states, `data.find(b";", i)` is deleted, and the `length` parameter is passed to `data.find(b"&", i, length)` to bound the search to the current chunk. The test is updated to verify that `;` is treated as ordinary field data rather than a separator [patch_id=6110810].

Preconditions

  • configThe upstream intermediary (WAF, gateway, or proxy) must parse `application/x-www-form-urlencoded` bodies using the WHATWG rule (only `&` as separator).
  • configThe backend application must use `python-multipart`'s `QuerystringParser` (e.g. via Starlette/FastAPI `request.form()`) to parse the same body.
  • networkThe attacker must be able to send HTTP requests with a url-encoded body containing `;` characters.

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.