The docstring says a body without a Content-Type header "will not be parsed as JSON." For one Pydantic type, it is.
| Affects | FastAPI 0.140.0 → 0.141.1 (latest at time of writing) |
| Status | Unfixed |
| Reported | 2026-07-26 |
| Declined | 2026-08-11 |
Third of five write-ups on findings reported privately to FastAPI and closed without publication.
The background you need
In 2021 FastAPI shipped a security fix for CVE-2021-32677. The project's own release notes describe the vulnerability:
In versions lower than
0.65.2, FastAPI would try to read the request payload as JSON even if thecontent-typeheader sent was not set toapplication/jsonor a compatible JSON media type. [...] But requests with content typetext/plainare exempt from CORS preflights, for being considered Simple requests. So, the browser would execute them right away including cookies, and the text content could be a JSON string that would be parsed and accepted by the FastAPI application.
FastAPI assigned that a CVE, credited the reporter, and fixed it. So the project has already ruled that JSON being extracted from a request whose content type is not JSON is FastAPI's problem, not the application's.
In February 2026 that protection was formalised as a configurable feature, strict_content_type, shipped under the project's security commit prefix, with its own documentation page describing the threat model — a local or internal app, no authentication, a malicious page using fetch() with a Blob body to avoid a CORS preflight.
What breaks
Declare a request body as Pydantic's Json[T] and the protection stops applying.
from fastapi import FastAPI
from pydantic import BaseModel, Json
app = FastAPI()
actions: list[str] = []
class Action(BaseModel):
command: str
@app.post("/json-wrapper")
async def json_wrapper(action: Json[Action]):
actions.append(action.command)
return {"command": action.command}
@app.post("/ordinary")
async def ordinary(action: Action):
actions.append(action.command)
return {"command": action.command}
Send the identical bytes {"command":"transfer"} to both routes across three content types. The results are exact inverses:
| Body declaration | no Content-Type
|
text/plain |
application/json |
|---|---|---|---|
Json[Action] |
200 — executes | 200 — executes | 422 |
Action |
422 | 422 | 200 |
The Json[Action] route runs on precisely the preflight-free request shapes the protection exists to stop, and rejects properly-typed JSON.
Where it is
In fastapi/routing.py. When the content type is missing or isn't a JSON media type, FastAPI declines to call its own JSON parser — and then hands the raw bytes onward:
content_type_value = request.headers.get("content-type") # L436
if not content_type_value:
if not actual_strict_content_type:
json_body = await request.json()
else:
... # only application/json or */*+json get parsed
if json_body != Undefined:
body = json_body
else:
body = body_bytes # L450
Nothing records that JSON interpretation was refused. The bytes go into validation carrying no memory of the decision, and Json[T] — whose entire meaning is "parse these bytes as JSON" — does exactly that.
The decision is made at one layer and undone at the next, inside a pipeline FastAPI owns end to end.
The guarantee in writing
This is the part that moves it beyond an inferred contract. FastAPI's own API documentation for the parameter states:
Enable strict checking for request
Content-Typeheaders.When
True(the default), requests with a body that do not include aContent-Typeheader will not be parsed as JSON.
For Json[T], the body is parsed as JSON. Not by Starlette, by Pydantic — but the sentence doesn't distinguish, and neither does an attacker.
How it got here
The history is unusually legible.
The raw-bytes fallback on line 450 is not new. git blame traces it back to PR #2118 — the 2021 fix for CVE-2021-32677 itself. The 2026 strict_content_type feature added two lines above it and left the fallback untouched.
So a new security control was bolted onto a four-year-old escape hatch without the hatch being re-examined.
The test suite tells the same story. All three strict_content_type test files declare exactly one body type:
async def app_default_post(data: dict): ...
async def app_lax_post(data: dict): ...
Every route in every test, data: dict. Meanwhile Json[T] is a supported type with its own test file — covering Form(), Query(), Header() and Cookie() positions, and never a request body.
The body-type space was never enumerated when the protection was designed.
Is it a vulnerability or a bug?
Why the framework is at fault
A written guarantee is false. Not a reasonable inference from the docs — the actual docstring for the actual parameter. That's the strongest single fact here.
FastAPI has already ruled on this mechanism. CVE-2021-32677 assigned exactly this behaviour to FastAPI, with a CVE and a credited reporter. Json[T] reaches the same end state by a different route.
FastAPI owns the integration. The whole value proposition of the framework is the Pydantic integration. "Starlette refused to parse it but Pydantic did" is not a boundary an application author can see or reason about.
The gap is an unconsidered case, not a decision. One body type in the tests, and Json[T] tested only in non-body positions. Nothing in the code comments or the docs acknowledges the fallback as security-relevant.
Why it may be only a bug — and this side is strong
A top-level Json[T] body genuinely is a bytes body. I measured Annotated[bytes, Body()] and it behaves identically: 200 with no content type, 200 with text/plain, 422 with application/json. Accepting a raw byte body without a content type is intentional, longstanding, documented behaviour. Json[T] rides that path and then re-parses. FastAPI never treated the request as JSON; the application asked for bytes and chose a type that reinterprets them.
It's non-idiomatic. You'd write action: Action. Nobody reaches for action: Json[Action] as a request body on purpose — and such a route rejects real application/json clients with a 422, so the author's own testing would surface it immediately.
The documented threat model is narrow. The feature's own page scopes it to local or internal apps with no authentication, and says plainly that for an app on the open internet "this attack / risk doesn't apply to you." My own proof-of-concept sends no credentials, which is consistent with that scope but also limits what it demonstrates.
Where I land
A real gap in a documented security control, and a weak severity claim. Those can both be true.
The honest framing isn't "FastAPI has a CSRF vulnerability." It's that the guarantee as written is broader than the guarantee as implemented, and one documented Pydantic type falls in the space between. The fix is either to narrow the docstring or to make the content-type decision bind before a top-level Json[T] is validated — a property of the request rather than of the field type.
What you can do today
If you're relying on strict_content_type as a barrier for a local or internal service:
-
Don't use top-level
Json[T]on privileged state-changing endpoints. Use the model directly. - Reject unacceptable media types in middleware, before body validation runs, if you need the guarantee to hold regardless of field type.
- Add real authentication and Origin/CSRF checks where you can. Content type alone was never a complete CSRF design, and FastAPI's own documentation says so.
The fix
When strict content-type checking refuses JSON interpretation, a type whose meaning is "parse this as JSON" should not be able to undo that during validation. Either enforce an accepted JSON media type before validating a top-level Json[T], or carry the refusal forward in a form validation can see.
Failing that, narrow the docstring so it describes what actually happens.
Regression coverage should include a missing content type, text/plain, accepted JSON types including structured +json, legitimate non-JSON byte bodies, and applications running with strict_content_type=False.
Status
| Date | Event |
|---|---|
| 2021-06-09 | CVE-2021-32677 — content-type CSRF fixed, reporter credited |
| 2026-02-23 |
strict_content_type shipped as a security feature — PR #14978
|
| 2026-07-26 | Reported privately as GHSA-7mw5-87j8-54ww, severity medium, CWE-352 / CWE-693 |
| 2026-08-11 | Closed without publication, submission.accepted: false
|
| 2026-08-12 | Re-verified against 0.141.1. Still present. No fix |
The advisory is private, so that ID is citable but not a link a you can follow.
Verified by reading FastAPI's source at tag 0.141.1 and running the reproduction on 2026-08-12. Code links are pinned to that tag rather than master, so the line numbers stay valid.
Top comments (0)