From 06438a3157cde80b81af2572166cf72e6bf1a685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 23 Feb 2026 17:48:54 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20`strict=5Fcontent?= =?UTF-8?q?=5Ftype`=20checking=20for=20JSON=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/applications.py | 24 ++++++++++++++++++++++++ fastapi/routing.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) 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..cf8ae57a1d 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 = 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 @@ -370,7 +371,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 strict_content_type: + json_body = await request.json() else: message = email.message.Message() message["content-type"] = content_type_value @@ -599,6 +601,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 = True, ) -> None: self.path = path self.endpoint = endpoint @@ -625,6 +628,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 +717,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 +968,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/). + """ + ), + ] = True, ) -> None: # Determine the lifespan context to use if lifespan is None: @@ -1009,6 +1037,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 +1088,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 | None = None, ) -> None: route_class = route_class_override or self.route_class responses = responses or {} @@ -1105,6 +1135,9 @@ class APIRouter(routing.Router): callbacks=current_callbacks, openapi_extra=openapi_extra, generate_unique_id_function=current_generate_unique_id, + strict_content_type=strict_content_type + if strict_content_type is not None + else self.strict_content_type, ) self.routes.append(route) @@ -1480,6 +1513,7 @@ class APIRouter(routing.Router): callbacks=current_callbacks, openapi_extra=route.openapi_extra, generate_unique_id_function=current_generate_unique_id, + strict_content_type=route.strict_content_type, ) elif isinstance(route, routing.Route): methods = list(route.methods or [])