From 22354a253037e0fb23e55dabcb8767943e371702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 23 Feb 2026 09:45:20 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20`strict=5Fcontent?= =?UTF-8?q?=5Ftype`=20checking=20for=20JSON=20requests=20(#14978)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/advanced/strict-content-type.md | 88 ++++++++++++++++++ docs/en/mkdocs.yml | 1 + docs_src/strict_content_type/__init__.py | 0 .../strict_content_type/tutorial001_py310.py | 14 +++ fastapi/applications.py | 24 +++++ fastapi/routing.py | 44 ++++++++- tests/test_strict_content_type_app_level.py | 44 +++++++++ tests/test_strict_content_type_nested.py | 91 +++++++++++++++++++ .../test_strict_content_type_router_level.py | 61 +++++++++++++ .../test_body/test_tutorial001.py | 10 +- .../test_strict_content_type/__init__.py | 0 .../test_tutorial001.py | 43 +++++++++ 12 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 docs/en/docs/advanced/strict-content-type.md create mode 100644 docs_src/strict_content_type/__init__.py create mode 100644 docs_src/strict_content_type/tutorial001_py310.py create mode 100644 tests/test_strict_content_type_app_level.py create mode 100644 tests/test_strict_content_type_nested.py create mode 100644 tests/test_strict_content_type_router_level.py create mode 100644 tests/test_tutorial/test_strict_content_type/__init__.py create mode 100644 tests/test_tutorial/test_strict_content_type/test_tutorial001.py diff --git a/docs/en/docs/advanced/strict-content-type.md b/docs/en/docs/advanced/strict-content-type.md new file mode 100644 index 0000000000..54c099410c --- /dev/null +++ b/docs/en/docs/advanced/strict-content-type.md @@ -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. + +/// diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index b276e55d95..e86e7b9c41 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -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 diff --git a/docs_src/strict_content_type/__init__.py b/docs_src/strict_content_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/strict_content_type/tutorial001_py310.py b/docs_src/strict_content_type/tutorial001_py310.py new file mode 100644 index 0000000000..a44f4b1386 --- /dev/null +++ b/docs_src/strict_content_type/tutorial001_py310.py @@ -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 diff --git a/fastapi/applications.py b/fastapi/applications.py index 41d86143ec..ed05a1ff9e 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -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]] diff --git a/fastapi/routing.py b/fastapi/routing.py index 528c962965..d17650a627 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -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 []) diff --git a/tests/test_strict_content_type_app_level.py b/tests/test_strict_content_type_app_level.py new file mode 100644 index 0000000000..42a0821a47 --- /dev/null +++ b/tests/test_strict_content_type_app_level.py @@ -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"} diff --git a/tests/test_strict_content_type_nested.py b/tests/test_strict_content_type_nested.py new file mode 100644 index 0000000000..922d01571a --- /dev/null +++ b/tests/test_strict_content_type_nested.py @@ -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 diff --git a/tests/test_strict_content_type_router_level.py b/tests/test_strict_content_type_router_level.py new file mode 100644 index 0000000000..72a02d6c91 --- /dev/null +++ b/tests/test_strict_content_type_router_level.py @@ -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 diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index bdabf8d68b..8c883708a3 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -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): diff --git a/tests/test_tutorial/test_strict_content_type/__init__.py b/tests/test_tutorial/test_strict_content_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_strict_content_type/test_tutorial001.py b/tests/test_tutorial/test_strict_content_type/test_tutorial001.py new file mode 100644 index 0000000000..81e2d3a0be --- /dev/null +++ b/tests/test_tutorial/test_strict_content_type/test_tutorial001.py @@ -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