🔒️ Add `strict_content_type` checking for JSON requests (#14978)

This commit is contained in:
Sebastián Ramírez 2026-02-23 09:45:20 -08:00 committed by GitHub
parent 94a1ee749e
commit 22354a2530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 411 additions and 9 deletions

View File

@ -0,0 +1,88 @@
# Strict Content-Type Checking { #strict-content-type-checking }
By default, **FastAPI** uses strict `Content-Type` header checking for JSON request bodies, this means that JSON requests **must** include a valid `Content-Type` header (e.g. `application/json`) in order for the body to be parsed as JSON.
## CSRF Risk { #csrf-risk }
This default behavior provides protection against a class of **Cross-Site Request Forgery (CSRF)** attacks in a very specific scenario.
These attacks exploit the fact that browsers allow scripts to send requests without doing any CORS preflight check when they:
* don't have a `Content-Type` header (e.g. using `fetch()` with a `Blob` body)
* and don't send any authentication credentials.
This type of attack is mainly relevant when:
* the application is running locally (e.g. on `localhost`) or in an internal network
* and the application doesn't have any authentication, it expects that any request from the same network can be trusted.
## Example Attack { #example-attack }
Imagine you build a way to run a local AI agent.
It provides an API at
```
http://localhost:8000/v1/agents/multivac
```
There's also a frontend at
```
http://localhost:8000
```
/// tip
Note that both have the same host.
///
Then using the frontend you can make the AI agent do things on your behalf.
As it's running **locally**, and not in the open internet, you decide to **not have any authentication** set up, just trusting the access to the local network.
Then one of your users could install it and run it locally.
Then they could open a malicious website, e.g. something like
```
https://evilhackers.example.com
```
And that malicious website sends requests using `fetch()` with a `Blob` body to the local API at
```
http://localhost:8000/v1/agents/multivac
```
Even though the host of the malicious website and the local app is different, the browser won't trigger a CORS preflight request because:
* It's running without any authentication, it doesn't have to send any credentials.
* The browser thinks it's not sending JSON (because of the missing `Content-Type` header).
Then the malicious website could make the local AI agent send angry messages to the user's ex-boss... or worse. 😅
## Open Internet { #open-internet }
If your app is in the open internet, you wouldn't "trust the network" and let anyone send privileged requests without authentication.
Attackers could simply run a script to send requests to your API, no need for browser interaction, so you are probably already securing any privileged endpoints.
In that case **this attack / risk doesn't apply to you**.
This risk and attack is mainly relevant when the app runs on the **local network** and that is the **only assumed protection**.
## Allowing Requests Without Content-Type { #allowing-requests-without-content-type }
If you need to support clients that don't send a `Content-Type` header, you can disable strict checking by setting `strict_content_type=False`:
{* ../../docs_src/strict_content_type/tutorial001_py310.py hl[4] *}
With this setting, requests without a `Content-Type` header will have their body parsed as JSON, which is the same behavior as older versions of FastAPI.
/// info
This behavior and configuration was added in FastAPI 0.132.0.
///

View File

@ -193,6 +193,7 @@ nav:
- advanced/generate-clients.md
- advanced/advanced-python-types.md
- advanced/json-base64-bytes.md
- advanced/strict-content-type.md
- fastapi-cli.md
- Deployment:
- deployment/index.md

View File

View File

@ -0,0 +1,14 @@
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(strict_content_type=False)
class Item(BaseModel):
name: str
price: float
@app.post("/items/")
async def create_item(item: Item):
return item

View File

@ -840,6 +840,29 @@ class FastAPI(Starlette):
"""
),
] = None,
strict_content_type: Annotated[
bool,
Doc(
"""
Enable strict checking for request Content-Type headers.
When `True` (the default), requests with a body that do not include
a `Content-Type` header will **not** be parsed as JSON.
This prevents potential cross-site request forgery (CSRF) attacks
that exploit the browser's ability to send requests without a
Content-Type header, bypassing CORS preflight checks. In particular
applicable for apps that need to be run locally (in localhost).
When `False`, requests without a `Content-Type` header will have
their body parsed as JSON, which maintains compatibility with
certain clients that don't send `Content-Type` headers.
Read more about it in the
[FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/).
"""
),
] = True,
**extra: Annotated[
Any,
Doc(
@ -974,6 +997,7 @@ class FastAPI(Starlette):
include_in_schema=include_in_schema,
responses=responses,
generate_unique_id_function=generate_unique_id_function,
strict_content_type=strict_content_type,
)
self.exception_handlers: dict[
Any, Callable[[Request, Any], Response | Awaitable[Response]]

View File

@ -329,6 +329,7 @@ def get_request_handler(
response_model_exclude_none: bool = False,
dependency_overrides_provider: Any | None = None,
embed_body_fields: bool = False,
strict_content_type: bool | DefaultPlaceholder = Default(True),
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
assert dependant.call is not None, "dependant.call must be a function"
is_coroutine = dependant.is_coroutine_callable
@ -337,6 +338,10 @@ def get_request_handler(
actual_response_class: type[Response] = response_class.value
else:
actual_response_class = response_class
if isinstance(strict_content_type, DefaultPlaceholder):
actual_strict_content_type: bool = strict_content_type.value
else:
actual_strict_content_type = strict_content_type
async def app(request: Request) -> Response:
response: Response | None = None
@ -370,7 +375,8 @@ def get_request_handler(
json_body: Any = Undefined
content_type_value = request.headers.get("content-type")
if not content_type_value:
json_body = await request.json()
if not actual_strict_content_type:
json_body = await request.json()
else:
message = email.message.Message()
message["content-type"] = content_type_value
@ -599,6 +605,7 @@ class APIRoute(routing.Route):
openapi_extra: dict[str, Any] | None = None,
generate_unique_id_function: Callable[["APIRoute"], str]
| DefaultPlaceholder = Default(generate_unique_id),
strict_content_type: bool | DefaultPlaceholder = Default(True),
) -> None:
self.path = path
self.endpoint = endpoint
@ -625,6 +632,7 @@ class APIRoute(routing.Route):
self.callbacks = callbacks
self.openapi_extra = openapi_extra
self.generate_unique_id_function = generate_unique_id_function
self.strict_content_type = strict_content_type
self.tags = tags or []
self.responses = responses or {}
self.name = get_name(endpoint) if name is None else name
@ -713,6 +721,7 @@ class APIRoute(routing.Route):
response_model_exclude_none=self.response_model_exclude_none,
dependency_overrides_provider=self.dependency_overrides_provider,
embed_body_fields=self._embed_body_fields,
strict_content_type=self.strict_content_type,
)
def matches(self, scope: Scope) -> tuple[Match, Scope]:
@ -963,6 +972,29 @@ class APIRouter(routing.Router):
"""
),
] = Default(generate_unique_id),
strict_content_type: Annotated[
bool,
Doc(
"""
Enable strict checking for request Content-Type headers.
When `True` (the default), requests with a body that do not include
a `Content-Type` header will **not** be parsed as JSON.
This prevents potential cross-site request forgery (CSRF) attacks
that exploit the browser's ability to send requests without a
Content-Type header, bypassing CORS preflight checks. In particular
applicable for apps that need to be run locally (in localhost).
When `False`, requests without a `Content-Type` header will have
their body parsed as JSON, which maintains compatibility with
certain clients that don't send `Content-Type` headers.
Read more about it in the
[FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/).
"""
),
] = Default(True),
) -> None:
# Determine the lifespan context to use
if lifespan is None:
@ -1009,6 +1041,7 @@ class APIRouter(routing.Router):
self.route_class = route_class
self.default_response_class = default_response_class
self.generate_unique_id_function = generate_unique_id_function
self.strict_content_type = strict_content_type
def route(
self,
@ -1059,6 +1092,7 @@ class APIRouter(routing.Router):
openapi_extra: dict[str, Any] | None = None,
generate_unique_id_function: Callable[[APIRoute], str]
| DefaultPlaceholder = Default(generate_unique_id),
strict_content_type: bool | DefaultPlaceholder = Default(True),
) -> None:
route_class = route_class_override or self.route_class
responses = responses or {}
@ -1105,6 +1139,9 @@ class APIRouter(routing.Router):
callbacks=current_callbacks,
openapi_extra=openapi_extra,
generate_unique_id_function=current_generate_unique_id,
strict_content_type=get_value_or_default(
strict_content_type, self.strict_content_type
),
)
self.routes.append(route)
@ -1480,6 +1517,11 @@ class APIRouter(routing.Router):
callbacks=current_callbacks,
openapi_extra=route.openapi_extra,
generate_unique_id_function=current_generate_unique_id,
strict_content_type=get_value_or_default(
route.strict_content_type,
router.strict_content_type,
self.strict_content_type,
),
)
elif isinstance(route, routing.Route):
methods = list(route.methods or [])

View File

@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
app_default = FastAPI()
@app_default.post("/items/")
async def app_default_post(data: dict):
return data
app_lax = FastAPI(strict_content_type=False)
@app_lax.post("/items/")
async def app_lax_post(data: dict):
return data
client_default = TestClient(app_default)
client_lax = TestClient(app_lax)
def test_default_strict_rejects_no_content_type():
response = client_default.post("/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_strict_accepts_json_content_type():
response = client_default.post("/items/", json={"key": "value"})
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_lax_accepts_no_content_type():
response = client_lax.post("/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_lax_accepts_json_content_type():
response = client_lax.post("/items/", json={"key": "value"})
assert response.status_code == 200
assert response.json() == {"key": "value"}

View File

@ -0,0 +1,91 @@
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
# Lax app with nested routers, inner overrides to strict
app_nested = FastAPI(strict_content_type=False) # lax app
outer_router = APIRouter(prefix="/outer") # inherits lax from app
inner_strict = APIRouter(prefix="/strict", strict_content_type=True)
inner_default = APIRouter(prefix="/default")
@inner_strict.post("/items/")
async def inner_strict_post(data: dict):
return data
@inner_default.post("/items/")
async def inner_default_post(data: dict):
return data
outer_router.include_router(inner_strict)
outer_router.include_router(inner_default)
app_nested.include_router(outer_router)
client_nested = TestClient(app_nested)
def test_strict_inner_on_lax_app_rejects_no_content_type():
response = client_nested.post("/outer/strict/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_inner_inherits_lax_from_app():
response = client_nested.post("/outer/default/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_inner_accepts_json_content_type():
response = client_nested.post("/outer/strict/items/", json={"key": "value"})
assert response.status_code == 200
def test_default_inner_accepts_json_content_type():
response = client_nested.post("/outer/default/items/", json={"key": "value"})
assert response.status_code == 200
# Strict app -> lax outer router -> strict inner router
app_mixed = FastAPI(strict_content_type=True)
mixed_outer = APIRouter(prefix="/outer", strict_content_type=False)
mixed_inner = APIRouter(prefix="/inner", strict_content_type=True)
@mixed_outer.post("/items/")
async def mixed_outer_post(data: dict):
return data
@mixed_inner.post("/items/")
async def mixed_inner_post(data: dict):
return data
mixed_outer.include_router(mixed_inner)
app_mixed.include_router(mixed_outer)
client_mixed = TestClient(app_mixed)
def test_lax_outer_on_strict_app_accepts_no_content_type():
response = client_mixed.post("/outer/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_inner_on_lax_outer_rejects_no_content_type():
response = client_mixed.post("/outer/inner/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_lax_outer_accepts_json_content_type():
response = client_mixed.post("/outer/items/", json={"key": "value"})
assert response.status_code == 200
def test_strict_inner_on_lax_outer_accepts_json_content_type():
response = client_mixed.post("/outer/inner/items/", json={"key": "value"})
assert response.status_code == 200

View File

@ -0,0 +1,61 @@
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
router_lax = APIRouter(prefix="/lax", strict_content_type=False)
router_strict = APIRouter(prefix="/strict", strict_content_type=True)
router_default = APIRouter(prefix="/default")
@router_lax.post("/items/")
async def router_lax_post(data: dict):
return data
@router_strict.post("/items/")
async def router_strict_post(data: dict):
return data
@router_default.post("/items/")
async def router_default_post(data: dict):
return data
app.include_router(router_lax)
app.include_router(router_strict)
app.include_router(router_default)
client = TestClient(app)
def test_lax_router_on_strict_app_accepts_no_content_type():
response = client.post("/lax/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_router_on_strict_app_rejects_no_content_type():
response = client.post("/strict/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_router_inherits_strict_from_app():
response = client.post("/default/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_lax_router_accepts_json_content_type():
response = client.post("/lax/items/", json={"key": "value"})
assert response.status_code == 200
def test_strict_router_accepts_json_content_type():
response = client.post("/strict/items/", json={"key": "value"})
assert response.status_code == 200
def test_default_router_accepts_json_content_type():
response = client.post("/default/items/", json={"key": "value"})
assert response.status_code == 200

View File

@ -189,18 +189,12 @@ def test_geo_json(client: TestClient):
assert response.status_code == 200, response.text
def test_no_content_type_is_json(client: TestClient):
def test_no_content_type_json(client: TestClient):
response = client.post(
"/items/",
content='{"name": "Foo", "price": 50.5}',
)
assert response.status_code == 200, response.text
assert response.json() == {
"name": "Foo",
"description": None,
"price": 50.5,
"tax": None,
}
assert response.status_code == 422, response.text
def test_wrong_headers(client: TestClient):

View File

@ -0,0 +1,43 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(
name="client",
params=[
"tutorial001_py310",
],
)
def get_client(request: pytest.FixtureRequest):
mod = importlib.import_module(f"docs_src.strict_content_type.{request.param}")
client = TestClient(mod.app)
return client
def test_lax_post_without_content_type_is_parsed_as_json(client: TestClient):
response = client.post(
"/items/",
content='{"name": "Foo", "price": 50.5}',
)
assert response.status_code == 200, response.text
assert response.json() == {"name": "Foo", "price": 50.5}
def test_lax_post_with_json_content_type(client: TestClient):
response = client.post(
"/items/",
json={"name": "Foo", "price": 50.5},
)
assert response.status_code == 200, response.text
assert response.json() == {"name": "Foo", "price": 50.5}
def test_lax_post_with_text_plain_is_still_rejected(client: TestClient):
response = client.post(
"/items/",
content='{"name": "Foo", "price": 50.5}',
headers={"Content-Type": "text/plain"},
)
assert response.status_code == 422, response.text