From 248d7fb9f5d3d0ac8202f7a03546441cd0f53c1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:49:53 +0100 Subject: [PATCH 1/8] =?UTF-8?q?=E2=AC=86=20Bump=20flask=20from=203.1.2=20t?= =?UTF-8?q?o=203.1.3=20(#14949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [flask](https://github.com/pallets/flask) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/flask/releases) - [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/flask/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: flask dependency-version: 3.1.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 0b14fde53..6faba9662 100644 --- a/uv.lock +++ b/uv.lock @@ -1607,7 +1607,7 @@ wheels = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blinker" }, @@ -1617,9 +1617,9 @@ dependencies = [ { name = "markupsafe" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] From 94a1ee749e63591e6e275b726a66e07a25f6c407 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 16:50:41 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index b42368614..51d82313f 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Internal +* ⬆ Bump flask from 3.1.2 to 3.1.3. PR [#14949](https://github.com/fastapi/fastapi/pull/14949) by [@dependabot[bot]](https://github.com/apps/dependabot). * ⬆ Update all dependencies to use `griffelib` instead of `griffe`. PR [#14973](https://github.com/fastapi/fastapi/pull/14973) by [@svlandeg](https://github.com/svlandeg). * 🔨 Fix `FastAPI People` workflow. PR [#14951](https://github.com/fastapi/fastapi/pull/14951) by [@YuriiMotov](https://github.com/YuriiMotov). * 👷 Do not run codspeed with coverage as it's not tracked. PR [#14966](https://github.com/fastapi/fastapi/pull/14966) by [@tiangolo](https://github.com/tiangolo). 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 3/8] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20`strict=5Fcon?= =?UTF-8?q?tent=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 000000000..54c099410 --- /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 b276e55d9..e86e7b9c4 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 000000000..e69de29bb 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 000000000..a44f4b138 --- /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 41d86143e..ed05a1ff9 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 528c96296..d17650a62 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 000000000..42a0821a4 --- /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 000000000..922d01571 --- /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 000000000..72a02d6c9 --- /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 bdabf8d68..8c883708a 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 000000000..e69de29bb 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 000000000..81e2d3a0b --- /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 From ac8621a76eba48c29cead5cd0dcdd77d446d37ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 17:46:11 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 51d82313f..0be7bbc9c 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Breaking Changes + +* 🔒️ Add `strict_content_type` checking for JSON requests. PR [#14978](https://github.com/fastapi/fastapi/pull/14978) by [@tiangolo](https://github.com/tiangolo). + ### Internal * ⬆ Bump flask from 3.1.2 to 3.1.3. PR [#14949](https://github.com/fastapi/fastapi/pull/14949) by [@dependabot[bot]](https://github.com/apps/dependabot). From 5c863d0718cea2e0ed812fbbfdc5844a0a34b039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 23 Feb 2026 18:46:57 +0100 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.132.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 0be7bbc9c..7ea70eceb 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.132.0 + ### Breaking Changes * 🔒️ Add `strict_content_type` checking for JSON requests. PR [#14978](https://github.com/fastapi/fastapi/pull/14978) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 944fdd58f..d936bb7df 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.131.0" +__version__ = "0.132.0" from starlette import status as status From acdf52e0c89f81952ab17bb7b34d67deb0d533dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 23 Feb 2026 18:54:18 +0100 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 7ea70eceb..08826adff 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -12,6 +12,9 @@ hide: ### Breaking Changes * 🔒️ Add `strict_content_type` checking for JSON requests. PR [#14978](https://github.com/fastapi/fastapi/pull/14978) by [@tiangolo](https://github.com/tiangolo). + * Now FastAPI checks, by default, that JSON requests have a `Content-Type` header with a valid JSON value, like `application/json`, and rejects requests that don't. + * If the clients for your app don't send a valid `Content-Type` header you can disable this with `strict_content_type=False`. + * Check the new docs: [Strict Content-Type Checking](https://fastapi.tiangolo.com/advanced/strict-content-type/). ### Internal From 6af38321261afde391dd28f60ec0dab9c15f0629 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:31:54 +0100 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=91=B7=20Allow=20skipping=20`benchmar?= =?UTF-8?q?k`=20job=20in=20`test`=20workflow=20(#14974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86e6bfc14..6046a4560 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -208,4 +208,4 @@ jobs: uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} - allowed-skips: coverage-combine,test + allowed-skips: coverage-combine,test,benchmark From 3f30ca1a5e2ed4cca1f8102b39a40e56151a018e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 18:32:32 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 08826adff..2b068a572 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Internal + +* 👷 Allow skipping `benchmark` job in `test` workflow. PR [#14974](https://github.com/fastapi/fastapi/pull/14974) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.132.0 ### Breaking Changes