]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🔒️ Add `strict_content_type` checking for JSON requests (#14978)
authorSebastián Ramírez <tiangolo@gmail.com>
Mon, 23 Feb 2026 17:45:20 +0000 (09:45 -0800)
committerGitHub <noreply@github.com>
Mon, 23 Feb 2026 17:45:20 +0000 (18:45 +0100)
12 files changed:
docs/en/docs/advanced/strict-content-type.md [new file with mode: 0644]
docs/en/mkdocs.yml
docs_src/strict_content_type/__init__.py [new file with mode: 0644]
docs_src/strict_content_type/tutorial001_py310.py [new file with mode: 0644]
fastapi/applications.py
fastapi/routing.py
tests/test_strict_content_type_app_level.py [new file with mode: 0644]
tests/test_strict_content_type_nested.py [new file with mode: 0644]
tests/test_strict_content_type_router_level.py [new file with mode: 0644]
tests/test_tutorial/test_body/test_tutorial001.py
tests/test_tutorial/test_strict_content_type/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_strict_content_type/test_tutorial001.py [new file with mode: 0644]

diff --git a/docs/en/docs/advanced/strict-content-type.md b/docs/en/docs/advanced/strict-content-type.md
new file mode 100644 (file)
index 0000000..54c0994
--- /dev/null
@@ -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.
+
+///
index b276e55d95aad29cbe5acae5f508cb1bd31dbeef..e86e7b9c4146b01c018dd36f926dd6a0b29c8e33 100644 (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
diff --git a/docs_src/strict_content_type/__init__.py b/docs_src/strict_content_type/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/docs_src/strict_content_type/tutorial001_py310.py b/docs_src/strict_content_type/tutorial001_py310.py
new file mode 100644 (file)
index 0000000..a44f4b1
--- /dev/null
@@ -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
index 41d86143ecbd47f2984d3330aedc0bd76559d881..ed05a1ff9e4578200d5212b769796e42e548a3e3 100644 (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]]
index 528c962965f856feb0298e814fb24e9d4d4fc00c..d17650a6277c4c43c149f6ecea95b43127e63171 100644 (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 [])
diff --git a/tests/test_strict_content_type_app_level.py b/tests/test_strict_content_type_app_level.py
new file mode 100644 (file)
index 0000000..42a0821
--- /dev/null
@@ -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 (file)
index 0000000..922d015
--- /dev/null
@@ -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 (file)
index 0000000..72a02d6
--- /dev/null
@@ -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
index bdabf8d68b3c35df6a625ab02fcedbc9b799427b..8c883708a3fb1c92b2cff85009e04c1fcdbaebf3 100644 (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):
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..81e2d3a
--- /dev/null
@@ -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