moizxsec
Disclosure 5 min read ★ selected

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.

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

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 for Form(...) 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-Type or strip Content-Length headers 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#

DateEvent
2026-04-22Discovered during manual review. PoC and root-cause walk-through completed.
2026-04-22Private security advisory opened on the upstream repository.
2026-06-12Fix released in starlette@1.3.1 (PRs #3329, #3331).
2026-06-15GHSA-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.