Compare commits

..

10 Commits

Author SHA1 Message Date
Sebastián Ramírez
daba0aa328 🔖 Release version 0.133.0 2026-02-24 10:51:43 +01:00
github-actions[bot]
0c3581d5c4 📝 Update release notes
[skip ci]
2026-02-24 09:50:02 +00:00
Sebastián Ramírez
c73bc94537 ⬆️ Add support for Starlette 1.0.0+ (#14987) 2026-02-24 09:49:32 +00:00
Sebastián Ramírez
6c68838615 🔖 Release version 0.132.1 2026-02-24 10:32:08 +01:00
github-actions[bot]
29d082ba24 📝 Update release notes
[skip ci]
2026-02-24 09:28:36 +00:00
Sebastián Ramírez
2686c7fbbf ♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data (#14986) 2026-02-24 09:28:10 +00:00
github-actions[bot]
2f9c914d44 📝 Update release notes
[skip ci]
2026-02-23 18:48:43 +00:00
Sebastián Ramírez
0cf27ecf88 👥 Update FastAPI People - Experts (#14972)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com>
2026-02-23 19:47:59 +01:00
github-actions[bot]
3f30ca1a5e 📝 Update release notes
[skip ci]
2026-02-23 18:32:32 +00:00
Motov Yurii
6af3832126 👷 Allow skipping benchmark job in test workflow (#14974) 2026-02-23 19:31:54 +01:00
10 changed files with 663 additions and 500 deletions

View File

@@ -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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,23 @@ hide:
## Latest Changes
## 0.133.0
### Upgrades
* ⬆️ Add support for Starlette 1.0.0+. PR [#14987](https://github.com/fastapi/fastapi/pull/14987) by [@tiangolo](https://github.com/tiangolo).
## 0.132.1
### Refactors
* ♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data. PR [#14986](https://github.com/fastapi/fastapi/pull/14986) by [@tiangolo](https://github.com/tiangolo).
### Internal
* 👥 Update FastAPI People - Experts. PR [#14972](https://github.com/fastapi/fastapi/pull/14972) by [@tiangolo](https://github.com/tiangolo).
* 👷 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

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.132.0"
__version__ = "0.133.0"
from starlette import status as status

View File

@@ -1101,16 +1101,18 @@ class FastAPI(Starlette):
def setup(self) -> None:
if self.openapi_url:
urls = (server_data.get("url") for server_data in self.servers)
server_urls = {url for url in urls if url}
async def openapi(req: Request) -> JSONResponse:
root_path = req.scope.get("root_path", "").rstrip("/")
if root_path not in server_urls:
if root_path and self.root_path_in_servers:
self.servers.insert(0, {"url": root_path})
server_urls.add(root_path)
return JSONResponse(self.openapi())
schema = self.openapi()
if root_path and self.root_path_in_servers:
server_urls = {s.get("url") for s in schema.get("servers", [])}
if root_path not in server_urls:
schema = dict(schema)
schema["servers"] = [{"url": root_path}] + schema.get(
"servers", []
)
return JSONResponse(schema)
self.add_route(self.openapi_url, openapi, include_in_schema=False)
if self.openapi_url and self.docs_url:

View File

@@ -5,6 +5,20 @@ from annotated_doc import Doc
from fastapi.encoders import jsonable_encoder
from starlette.responses import HTMLResponse
def _html_safe_json(value: Any) -> str:
"""Serialize a value to JSON with HTML special characters escaped.
This prevents injection when the JSON is embedded inside a <script> tag.
"""
return (
json.dumps(value)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
swagger_ui_default_parameters: Annotated[
dict[str, Any],
Doc(
@@ -155,7 +169,7 @@ def get_swagger_ui_html(
"""
for key, value in current_swagger_ui_parameters.items():
html += f"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\n"
html += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n"
if oauth2_redirect_url:
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
@@ -169,7 +183,7 @@ def get_swagger_ui_html(
if init_oauth:
html += f"""
ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})
ui.initOAuth({_html_safe_json(jsonable_encoder(init_oauth))})
"""
html += """

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP",
]
dependencies = [
"starlette>=0.40.0,<1.0.0",
"starlette>=0.40.0",
"pydantic>=2.7.0",
"typing-extensions>=4.8.0",
"typing-inspection>=0.4.2",

View File

@@ -0,0 +1,75 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_root_path_does_not_persist_across_requests():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
# Attacker request with a spoofed root_path
attacker_client = TestClient(app, root_path="/evil-api")
response1 = attacker_client.get("/openapi.json")
data1 = response1.json()
assert any(s.get("url") == "/evil-api" for s in data1.get("servers", []))
# Subsequent legitimate request with no root_path
clean_client = TestClient(app)
response2 = clean_client.get("/openapi.json")
data2 = response2.json()
servers = [s.get("url") for s in data2.get("servers", [])]
assert "/evil-api" not in servers
def test_multiple_different_root_paths_do_not_accumulate():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
for prefix in ["/path-a", "/path-b", "/path-c"]:
c = TestClient(app, root_path=prefix)
c.get("/openapi.json")
# A clean request should not have any of them
clean_client = TestClient(app)
response = clean_client.get("/openapi.json")
data = response.json()
servers = [s.get("url") for s in data.get("servers", [])]
for prefix in ["/path-a", "/path-b", "/path-c"]:
assert prefix not in servers, (
f"root_path '{prefix}' leaked into clean request: {servers}"
)
def test_legitimate_root_path_still_appears():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
client = TestClient(app, root_path="/api/v1")
response = client.get("/openapi.json")
data = response.json()
servers = [s.get("url") for s in data.get("servers", [])]
assert "/api/v1" in servers
def test_configured_servers_not_mutated():
configured_servers = [{"url": "https://prod.example.com"}]
app = FastAPI(servers=configured_servers)
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
# Request with a rogue root_path
attacker_client = TestClient(app, root_path="/evil")
attacker_client.get("/openapi.json")
# The original servers list must be untouched
assert configured_servers == [{"url": "https://prod.example.com"}]

View File

@@ -0,0 +1,37 @@
from fastapi.openapi.docs import get_swagger_ui_html
def test_init_oauth_html_chars_are_escaped():
xss_payload = "Evil</script><script>alert(1)</script>"
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
init_oauth={"appName": xss_payload},
)
body = html.body.decode()
assert "</script><script>" not in body
assert "\\u003c/script\\u003e\\u003cscript\\u003e" in body
def test_swagger_ui_parameters_html_chars_are_escaped():
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
swagger_ui_parameters={"customKey": "<img src=x onerror=alert(1)>"},
)
body = html.body.decode()
assert "<img src=x onerror=alert(1)>" not in body
assert "\\u003cimg" in body
def test_normal_init_oauth_still_works():
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
init_oauth={"clientId": "my-client", "appName": "My App"},
)
body = html.body.decode()
assert '"clientId": "my-client"' in body
assert '"appName": "My App"' in body
assert "ui.initOAuth" in body

62
uv.lock generated
View File

@@ -192,7 +192,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.78.0"
version = "0.83.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -204,9 +204,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" },
]
[[package]]
@@ -1242,7 +1242,7 @@ requires-dist = [
{ name = "python-multipart", marker = "extra == 'standard'", specifier = ">=0.0.18" },
{ name = "python-multipart", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.18" },
{ name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" },
{ name = "starlette", specifier = ">=0.40.0,<1.0.0" },
{ name = "starlette", specifier = ">=0.40.0" },
{ name = "typing-extensions", specifier = ">=4.8.0" },
{ name = "typing-inspection", specifier = ">=0.4.2" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.12.0" },
@@ -1922,14 +1922,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
]
[[package]]
name = "griffelib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
]
[[package]]
name = "griffe-typingdoc"
version = "0.3.1"
@@ -1955,6 +1947,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/3c/c2a9eee79bf2c8002d2fa370534bee93fdca39e8b1fc82e83d552d5d2c07/griffe_warnings_deprecated-1.1.1-py3-none-any.whl", hash = "sha256:4b7d765e82ca9139ed44ffe7bdebed0d3a46ce014ad5a35a2c22e9a16288737a", size = 6565, upload-time = "2026-02-20T15:35:23.577Z" },
]
[[package]]
name = "griffelib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
]
[[package]]
name = "groq"
version = "1.0.0"
@@ -2162,26 +2162,23 @@ wheels = [
[[package]]
name = "huggingface-hub"
version = "0.36.2"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "httpx" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "shellingham" },
{ name = "tqdm" },
{ name = "typer-slim" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
]
[package.optional-dependencies]
inference = [
{ name = "aiohttp" },
{ url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" },
]
[[package]]
@@ -3995,7 +3992,7 @@ groq = [
{ name = "groq" },
]
huggingface = [
{ name = "huggingface-hub", extra = ["inference"] },
{ name = "huggingface-hub" },
]
logfire = [
{ name = "logfire", extra = ["httpx"] },
@@ -4147,7 +4144,7 @@ wheels = [
[[package]]
name = "pydantic-evals"
version = "1.56.0"
version = "1.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -4157,9 +4154,9 @@ dependencies = [
{ name = "pyyaml" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/f2/8c59284a2978af3fbda45ae3217218eaf8b071207a9290b54b7613983e5d/pydantic_evals-1.56.0.tar.gz", hash = "sha256:206635107127af6a3ee4b1fc8f77af6afb14683615a2d6b3609f79467c1c0d28", size = 47210, upload-time = "2026-02-06T01:13:25.714Z" }
sdist = { url = "https://files.pythonhosted.org/packages/23/90/080f6722412263395d1d6d066ee90fa8bc2722ce097844220c2d9c946877/pydantic_evals-1.62.0.tar.gz", hash = "sha256:198c4bee936718a4acf6f504056b113e60b34eb49021df8889a394e14c803693", size = 56434, upload-time = "2026-02-19T05:07:11.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/51/9875d19ff6d584aaeb574aba76b49d931b822546fc60b29c4fc0da98170d/pydantic_evals-1.56.0-py3-none-any.whl", hash = "sha256:d1efb410c97135aabd2a22453b10c981b2b9851985e9354713af67ae0973b7a9", size = 56407, upload-time = "2026-02-06T01:13:17.098Z" },
{ url = "https://files.pythonhosted.org/packages/d8/b9/dc8dba744ec02b16c6fd1abe3fd8ef1b00fd05c72feef5069851b811952f/pydantic_evals-1.62.0-py3-none-any.whl", hash = "sha256:0ca7e10037ed90393c54b6cff41370d6d4bac63f8c878715599c58863c303db1", size = 67341, upload-time = "2026-02-19T05:07:03.83Z" },
]
[[package]]
@@ -5558,6 +5555,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
]
[[package]]
name = "typer-slim"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
]
[[package]]
name = "types-orjson"
version = "3.6.2"