python-multipart: Semicolon treated as querystring field separator enables parameter smuggling
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- Range: < 0.0.30
Patches
1d69df35cd2caTreat only `&` as the urlencoded field separator (#290)
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
2News mentions
0No linked articles in our index yet.