Starlette: Form-Parser Limits Silently Ignored for URL-Encoded Bodies
Starlette's Request.form() advertises max_fields, max_files, and max_part_size as resource-consumption guards. They are correctly enforced for multipart/form-data but quietly dropped on the application/x-www-form-urlencoded path, so any FastAPI or Starlette app that calls request.form() and accepts URL-encoded bodies is exposed to a one-request event-loop-blocking DoS.
- CVE
- CVE-2026-54283
- GHSA
- GHSA-82w8-qh3p-5jfq
- Severity
- High 7.5
- Vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Weakness
- CWE-770
- Affected
- starlette >= 0.4.1, < 1.3.1 PyPI
- Fixed in
- 1.3.1
- Vendor
- encode/starlette
- Status
- Patched
- Published
- Advisory
- vendor advisory
Starlette is the ASGI framework that
FastAPI is built on. Anywhere a FastAPI route calls
await request.form() (even indirectly, via a dependency or a third-party library), it
hands the request body to a Starlette form parser. Starlette exposes three knobs to keep that
parser bounded:
async with request.form(
max_fields=100,
max_files=10,
max_part_size=1 * 1024 * 1024,
) as form:
...
These limits are real and behave correctly for multipart/form-data. They are silently
ignored for application/x-www-form-urlencoded. An attacker can send a URL-encoded body of
arbitrary size containing hundreds of thousands of fields, and the parser will accept all of
it, blocking the async event loop on every concurrent request.
The advisory is
GHSA-82w8-qh3p-5jfq / CVE-2026-54283,
CVSS 7.5, fixed by the maintainer (Marcelo Trylesinski) in starlette@1.3.1. The issue was
reported independently by several researchers; I am one of the credited reporters on the
advisory. Affected range: >= 0.4.1, < 1.3.1.
The gap#
The advertised guards exist on Request._get_form(). Walking the code from the entry point:
# starlette/requests.py
if content_type == b"multipart/form-data":
form_parser = MultiPartParser(
self.headers,
self.stream(),
max_files=max_files,
max_fields=max_fields,
max_part_size=max_part_size,
)
self._form = await form_parser.parse()
elif content_type == b"application/x-www-form-urlencoded":
form_parser = FormParser(self.headers, self.stream()) # ← limits not passed
self._form = await form_parser.parse()
MultiPartParser accepts all three guards. FormParser accepts none of them: its
__init__ signature is (headers, stream). Even if Request._get_form() were updated to
pass the kwargs, the receiver would silently drop them. The bug is in the contract between
the two classes, not in any single arithmetic expression.
The parser internally uses python-multipart’s QuerystringParser, which itself supports a
max_size parameter. Starlette never plumbs that value through, so the
QuerystringParser runs at its default inf.
Demonstrating the impact#
A minimal endpoint that thinks it has set hard limits:
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.requests import Request
async def form_endpoint(request: Request):
async with request.form(max_fields=10, max_part_size=512) as form:
return JSONResponse({"fields": len(form.multi_items())})
app = Starlette(routes=[Route("/form", form_endpoint, methods=["POST"])])
Multipart: the limits work#
POST /form HTTP/1.1
Content-Type: multipart/form-data; boundary=---bd
(50 multipart fields)
→ HTTP 400 Bad Request. As expected: max_fields=10 is exceeded and the parser raises.
URL-encoded: the limits do not work#
POST /form HTTP/1.1
Content-Type: application/x-www-form-urlencoded
a=1&a=2&...&a=500000
→ HTTP 200. The application sees 500,000 form items and happily returns the count.
Push it harder#
At roughly a million fields, the parser’s serial work inside QuerystringParser blocks the
async event loop for around 8.4 seconds per request, during which the worker cannot
process any other request: health checks, in-flight users, anything. The cost to the
attacker is one HTTP request at a few megabytes; the cost to the server is wall-clock latency
for everything sharing that worker.
That is the DoS shape: a few cheap requests from a single source consume seconds of CPU each and lock up the worker pool.
Discovery#
I found this during a manual review of a FastAPI-fronted internal service that explicitly
documented its max_fields guard. Out of habit I tried to abuse the documented limit before
trusting it, swapping the request Content-Type between multipart/form-data and
application/x-www-form-urlencoded while keeping the same payload shape. Multipart was
rejected the way the docs promised. URL-encoded sailed through.
That mismatch (same parser, same guard, opposite outcome) is what surfaces the bug. From
there it’s a fifteen-minute walk through Request._get_form to confirm the kwargs aren’t
passed to FormParser, and through FormParser.__init__ to confirm there’s nowhere to pass
them anyway.
The fix that shipped#
The maintainer closed the gap across two commits, released in 1.3.1. Both of the changes I
expected from reading the code landed, and the enforcement point ended up cleaner than I had
sketched.
1. FormParser learned the limits (#3329).
Its __init__ gained the two keyword-only guards, with defaults matching
multipart/form-data:
# starlette/formparsers.py
class FormParser:
def __init__(
self,
headers: Headers,
stream: AsyncGenerator[bytes, None],
*,
max_fields: int | float = 1000,
max_part_size: int = 1024 * 1024, # 1MB
) -> None:
...
self.max_fields = max_fields
self.max_part_size = max_part_size
2. Request._get_form now plumbs the values through and, crucially, wraps the url-encoded
path in the same MultiPartException → HTTPException(400) handler the multipart path already
had, so the limit failure becomes a clean 400 instead of a surfaced exception:
# starlette/requests.py
elif content_type == b"application/x-www-form-urlencoded":
try:
form_parser = FormParser(
self.headers,
self.stream(),
max_fields=max_fields,
max_part_size=max_part_size,
)
self._form = await form_parser.parse()
except MultiPartException as exc:
if "app" in self.scope:
raise HTTPException(status_code=400, detail=exc.message)
raise exc
3. Enforcement moved into the parser callbacks
(#3331). The first cut counted fields and
summed sizes in the message-drain loop; the follow-up pushed the checks into
on_field_name, on_field_data, and on_field_end, so the parser raises as it reads rather
than after a field has already been accumulated in memory:
def on_field_name(self, data: bytes, start: int, end: int) -> None:
self._current_field_size += end - start
if self._current_field_size > self.max_part_size:
raise MultiPartException(f"Field exceeded maximum size of {int(self.max_part_size / 1024)}KB.")
...
def on_field_end(self) -> None:
self._current_fields += 1
if self._current_fields > self.max_fields:
raise MultiPartException(f"Too many fields. Maximum number of fields is {self.max_fields}.")
...
That ordering matters for the size variant of the attack: raising inside the callback means the
oversized field is rejected while it is still arriving, before 50 MB has been buffered into a
bytearray. With these changes, Request.form(max_fields=10, max_part_size=512) behaves
identically for multipart and URL-encoded bodies, and the documented contract is restored.
Upgrade to 1.3.1 or later.
Why it matters#
The risk profile of the bug is shaped almost entirely by who calls request.form():
- Any FastAPI route that accepts a form. The framework idiomatically uses
request.form()under the hood forForm(...)parameters. - Any custom middleware or dependency that reads the form body before the route handler.
- Any application that proxies un-trusted clients to a Starlette worker pool without an
upstream body-size limit. Reverse proxies that normalise
Content-Typeor stripContent-Lengthheaders make the upstream guard easier to skip.
The CVSS 7.5 reflects AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H: network-reachable,
zero-interaction, no auth, full availability impact on a single host. Distributed across a
worker pool it scales linearly.
Timeline#
| Date | Event |
|---|---|
| 2026-04-22 | Discovered during manual review. PoC and root-cause walk-through completed. |
| 2026-04-22 | Private security advisory opened on the upstream repository. |
| 2026-06-12 | Fix released in starlette@1.3.1 (PRs #3329, #3331). |
| 2026-06-15 | GHSA-82w8-qh3p-5jfq / CVE-2026-54283 published, CVSS 7.5. |
Takeaways#
- Trust your own threat model, once. The documented guards on
request.form()were the reason I was confident the upstream service was safe from URL-encoded body amplification. That confidence was the bug. - Parsers should fail loud on dropped kwargs. If
FormParser.__init__is going to be given limits it cannot honour, it should refuse the call rather than ignore the keywords. - One contract, two implementations. Anywhere a framework wraps two parsers behind one API, the guards should be enforced at the wrapper, not at each implementation.
- Manual review still finds things. Static analysis would have flagged the unused kwargs if it had been configured to do so. Reading the code at the boundary, on suspicion, is faster.