CVE-2021-32677: Cross-Site Request Forgery (CSRF) (CWE-352)
value: FastAPI is a web framework for building APIs with Python 3.6+ based on standard Python type hints. FastAPI versions lower than 0.65.2 that used cookies for authentication in path operations that received JSON payloads sent by browsers were vulnerable to a Cross-Site Request Forgery (CSRF) attack. In versions lower than 0.65.2, FastAPI would try to read the request payload as JSON even if the content-type header sent was not set to application/json or a compatible JSON media type (e.g. application/geo+json). A request with a content type of text/plain containing JSON data would be accepted and the JSON data would be extracted. Requests with content type text/plain are exempt from CORS preflights, for being considered Simple requests. The browser will execute them right away including cookies, and the text content could be a JSON string that would be parsed and accepted by the FastAPI application. This is fixed in FastAPI 0.65.2. The request data is now parsed as JSON only if the content-type header is application/json or another JSON compatible media type like application/geo+json. It's best to upgrade to the latest FastAPI, but if updating is not possible then a middleware or a dependency that checks the content-type header and aborts the request if it is not application/json or another JSON compatible content type can act as a mitigating workaround.
CWE description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
CVSS v3 base score: 8.1
Published: 2021-06-09T18:15Z
Repository: https://github.com/tiangolo/fastapi
Language: Python
Files changed: fastapi/routing.py, tests/test_tutorial/test_body/test_tutorial001.py, tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py
Commit date: 2021-06-07 12:46:18 +0200
Commit message: 🐛 Check Content-Type request header before assuming JSON (#2118)

Co-authored-by: Patrick Wang <patrickkwang@users.noreply.github.com>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
Diff: diff --git a/fastapi/routing.py b/fastapi/routing.py
index ac5e19d..9b51f03 100644
--- a/fastapi/routing.py
+++ b/fastapi/routing.py
@@ -1,6 +1,7 @@
 import asyncio
+import email.message
 import enum
 import inspect
 import json
 from typing import (
     Any,
@@ -34,11 +35,11 @@ from fastapi.utils import (
     generate_operation_id_for_path,
     get_value_or_default,
 )
 from pydantic import BaseModel
 from pydantic.error_wrappers import ErrorWrapper, ValidationError
-from pydantic.fields import ModelField
+from pydantic.fields import ModelField, Undefined
 from starlette import routing
 from starlette.concurrency import run_in_threadpool
 from starlette.exceptions import HTTPException
 from starlette.requests import Request
 from starlette.responses import JSONResponse, Response
@@ -172,18 +173,30 @@ def get_request_handler(
     else:
         actual_response_class = response_class
 
     async def app(request: Request) -> Response:
         try:
-            body = None
+            body: Any = None
             if body_field:
                 if is_body_form:
                     body = await request.form()
                 else:
                     body_bytes = await request.body()
                     if body_bytes:
-                        body = await request.json()
+                        json_body: Any = Undefined
+                        content_type_value = request.headers.get("content-type")
+                        if content_type_value:
+                            message = email.message.Message()
+                            message["content-type"] = content_type_value
+                            if message.get_content_maintype() == "application":
+                                subtype = message.get_content_subtype()
+                                if subtype == "json" or subtype.endswith("+json"):
+                                    json_body = await request.json()
+                        if json_body != Undefined:
+                            body = json_body
+                        else:
+                            body = body_bytes
         except json.JSONDecodeError as e:
             raise RequestValidationError([ErrorWrapper(e, ("body", e.pos))], body=e.doc)
         except Exception as e:
             raise HTTPException(
                 status_code=400, detail="There was an error parsing the body"
diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py
index 38c6dbe..c90240a 100644
--- a/tests/test_tutorial/test_body/test_tutorial001.py
+++ b/tests/test_tutorial/test_body/test_tutorial001.py
@@ -171,27 +171,93 @@ def test_post_body(path, body, expected_status, expected_response):
     assert response.status_code == expected_status
     assert response.json() == expected_response
 
 
 def test_post_broken_body():
-    response = client.post("/items/", data={"name": "Foo", "price": 50.5})
+    response = client.post(
+        "/items/",
+        headers={"content-type": "application/json"},
+        data="{some broken json}",
+    )
     assert response.status_code == 422, response.text
     assert response.json() == {
         "detail": [
             {
+                "loc": ["body", 1],
+                "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)",
+                "type": "value_error.jsondecode",
                 "ctx": {
-                    "colno": 1,
-                    "doc": "name=Foo&price=50.5",
+                    "msg": "Expecting property name enclosed in double quotes",
+                    "doc": "{some broken json}",
+                    "pos": 1,
                     "lineno": 1,
-                    "msg": "Expecting value",
-                    "pos": 0,
+                    "colno": 2,
                 },
-                "loc": ["body", 0],
-                "msg": "Expecting value: line 1 column 1 (char 0)",
-                "type": "value_error.jsondecode",
             }
         ]
     }
+
+
+def test_post_form_for_json():
+    response = client.post("/items/", data={"name": "Foo", "price": 50.5})
+    assert response.status_code == 422, response.text
+    assert response.json() == {
+        "detail": [
+            {
+                "loc": ["body"],
+                "msg": "value is not a valid dict",
+                "type": "type_error.dict",
+            }
+        ]
+    }
+
+
+def test_explicit_content_type():
+    response = client.post(
+        "/items/",
+        data='{"name": "Foo", "price": 50.5}',
+        headers={"Content-Type": "application/json"},
+    )
+    assert response.status_code == 200, response.text
+
+
+def test_geo_json():
+    response = client.post(
+        "/items/",
+        data='{"name": "Foo", "price": 50.5}',
+        headers={"Content-Type": "application/geo+json"},
+    )
+    assert response.status_code == 200, response.text
+
+
+def test_wrong_headers():
+    data = '{"name": "Foo", "price": 50.5}'
+    invalid_dict = {
+        "detail": [
+            {
+                "loc": ["body"],
+                "msg": "value is not a valid dict",
+                "type": "type_error.dict",
+            }
+        ]
+    }
+
+    response = client.post("/items/", data=data, headers={"Content-Type": "text/plain"})
+    assert response.status_code == 422, response.text
+    assert response.json() == invalid_dict
+
+    response = client.post(
+        "/items/", data=data, headers={"Content-Type": "application/geo+json-seq"}
+    )
+    assert response.status_code == 422, response.text
+    assert response.json() == invalid_dict
+    response = client.post(
+        "/items/", data=data, headers={"Content-Type": "application/not-really-json"}
+    )
+    assert response.status_code == 422, response.text
+    assert response.json() == invalid_dict
+
+
+def test_other_exceptions():
     with patch("json.loads", side_effect=Exception):
         response = client.post("/items/", json={"test": "test2"})
         assert response.status_code == 400, response.text
-    assert response.json() == {"detail": "There was an error parsing the body"}
diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py
index cc85a8a..3eb5822 100644
--- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py
+++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py
@@ -23,10 +23,11 @@ def test_gzip_request(compress):
     body = [1] * n
     data = json.dumps(body).encode()
     if compress:
         data = gzip.compress(data)
         headers["Content-Encoding"] = "gzip"
+    headers["Content-Type"] = "application/json"
     response = client.post("/sum", data=data, headers=headers)
     assert response.json() == {"sum": n}
 
 
 def test_request_class():

Vulnerable code: from pydantic.fields import ModelField
            body = None
                        body = await request.json()
    response = client.post("/items/", data={"name": "Foo", "price": 50.5})
                    "colno": 1,
                    "doc": "name=Foo&price=50.5",
                    "msg": "Expecting value",
                    "pos": 0,
                "loc": ["body", 0],
                "msg": "Expecting value: line 1 column 1 (char 0)",
                "type": "value_error.jsondecode",
    assert response.json() == {"detail": "There was an error parsing the body"}
Fixed code: import email.message
from pydantic.fields import ModelField, Undefined
            body: Any = None
                        json_body: Any = Undefined
                        content_type_value = request.headers.get("content-type")
                        if content_type_value:
                            message = email.message.Message()
                            message["content-type"] = content_type_value
                            if message.get_content_maintype() == "application":
                                subtype = message.get_content_subtype()
                                if subtype == "json" or subtype.endswith("+json"):
                                    json_body = await request.json()
                        if json_body != Undefined:
                            body = json_body
                        else:
                            body = body_bytes
    response = client.post(
        "/items/",
        headers={"content-type": "application/json"},
        data="{some broken json}",
    )
                "loc": ["body", 1],
                "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)",
                "type": "value_error.jsondecode",
                    "msg": "Expecting property name enclosed in double quotes",
                    "doc": "{some broken json}",
                    "pos": 1,
                    "colno": 2,


def test_post_form_for_json():
    response = client.post("/items/", data={"name": "Foo", "price": 50.5})
    assert response.status_code == 422, response.text
    assert response.json() == {
        "detail": [
            {
                "loc": ["body"],
                "msg": "value is not a valid dict",
                "type": "type_error.dict",
            }
        ]
    }


def test_explicit_content_type():
    response = client.post(
        "/items/",
        data='{"name": "Foo", "price": 50.5}',
        headers={"Content-Type": "application/json"},
    )
    assert response.status_code == 200, response.text


def test_geo_json():
    response = client.post(
        "/items/",
        data='{"name": "Foo", "price": 50.5}',
        headers={"Content-Type": "application/geo+json"},
    )
    assert response.status_code == 200, response.text


def test_wrong_headers():
    data = '{"name": "Foo", "price": 50.5}'
    invalid_dict = {
        "detail": [
            {
                "loc": ["body"],
                "msg": "value is not a valid dict",
                "type": "type_error.dict",
            }
        ]
    }

    response = client.post("/items/", data=data, headers={"Content-Type": "text/plain"})
    assert response.status_code == 422, response.text
    assert response.json() == invalid_dict

    response = client.post(
        "/items/", data=data, headers={"Content-Type": "application/geo+json-seq"}
    )
    assert response.status_code == 422, response.text
    assert response.json() == invalid_dict
    response = client.post(
        "/items/", data=data, headers={"Content-Type": "application/not-really-json"}
    )
    assert response.status_code == 422, response.text
    assert response.json() == invalid_dict


def test_other_exceptions():
    headers["Content-Type"] = "application/json"