python-multipart: Quadratic-time querystring parsing with semicolon separators causes CPU denial of service
Description
Quadratic-time parsing in python-multipart's QuerystringParser when using semicolon separators allows CPU exhaustion via crafted form bodies.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Quadratic-time parsing in python-multipart's QuerystringParser when using semicolon separators allows CPU exhaustion via crafted form bodies.
Vulnerability
In python_multipart/multipart.py, the QuerystringParser locates field separators by first scanning the entire remaining buffer for & and only falling back to ; when no & is found. For a body that uses ; as the separator and contains no &, every field iteration performs a full failed & scan over the remaining buffer before finding the nearby ;. With N semicolon-separated fields in a chunk of size B, this yields O(B^2) byte comparisons per chunk [1][2]. All versions of python-multipart are affected. The parser is reachable through the public QuerystringParser class and the high-level FormParser, create_form_parser, and parse_form APIs for URL-encoded bodies. It is also the parser used by Starlette and FastAPI via request.form() [1][2].
Exploitation
An attacker can submit a small crafted body of the form a;a;a;... (e.g., 1 MiB of repeated a;) with Content-Type: application/x-www-form-urlencoded. No authentication or special network position is required. The parser performs on the order of 10^11 byte comparisons for a 1 MiB body, consuming several seconds of CPU per request [1][2]. A handful of concurrent requests can exhaust worker processes.
Impact
Uncontrolled CPU consumption leading to denial of service. Parsing is synchronous, so a single crafted form body occupies the handling worker for seconds, blocking any other work on that worker until parsing finishes. Sustained concurrent requests can keep workers busy indefinitely [1][2].
Mitigation
No fix has been published in the available references [1][2]. Users should monitor the python-multipart repository for a patched release. As a workaround, consider limiting the size of incoming request bodies or using an alternative parser for application/x-www-form-urlencoded data.
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
"Inefficient two-step separator lookup in `QuerystringParser` scans the entire remaining buffer for `&` on every field iteration, causing quadratic time complexity when the body uses `;` as the separator."
Attack vector
An attacker sends a crafted `application/x-www-form-urlencoded` body consisting of many semicolon-separated fields (e.g., `a;a;a;...`) with no `&` characters. Because the parser first scans the entire remaining buffer for `&` (which always fails) before scanning for `;`, each field iteration performs a full buffer scan, yielding O(B²) byte comparisons per chunk [CWE-400][CWE-407]. A small body (e.g., 1 MiB) can consume seconds of CPU per request, and a handful of concurrent requests can exhaust worker processes [ref_id=1][ref_id=2].
Affected code
The vulnerability is in `python_multipart/multipart.py` in the `QuerystringParser._internal_write` method, specifically in the `FIELD_NAME` and `FIELD_DATA` states. Both states used a two-step separator lookup: `sep_pos = data.find(b"&", i)` followed by `if sep_pos == -1: sep_pos = data.find(b";", i)`. This pattern caused a full buffer scan for `&` on every field iteration when the body used `;` as the separator.
What the fix does
The patch [patch_id=6110791] removes `;` as a recognized field separator, treating only `&` per the WHATWG URL standard. The `FIELD_NAME` and `FIELD_DATA` states now call `data.find(b"&", i, length)` with a bounded scan instead of scanning to the end of the buffer. This eliminates the failed full-buffer `&` scan on every field iteration, making parsing linear in the body length. The test is updated to confirm that `;` is treated as data rather than a separator.
Preconditions
- configThe target must accept `application/x-www-form-urlencoded` request bodies via `QuerystringParser`, `FormParser`, `create_form_parser`, or `parse_form` APIs.
- networkThe attacker must be able to send HTTP requests to the vulnerable endpoint (no authentication required).
- inputThe crafted body must use `;` as the field separator and contain no `&` 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.