mirror of
https://github.com/fastapi/fastapi.git
synced 2026-02-24 10:46:42 -05:00
Compare commits
6 Commits
rename-web
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daba0aa328 | ||
|
|
0c3581d5c4 | ||
|
|
c73bc94537 | ||
|
|
6c68838615 | ||
|
|
29d082ba24 | ||
|
|
2686c7fbbf |
@@ -7,6 +7,18 @@ 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 += """
|
||||
|
||||
@@ -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",
|
||||
|
||||
75
tests/test_openapi_cache_root_path.py
Normal file
75
tests/test_openapi_cache_root_path.py
Normal 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"}]
|
||||
37
tests/test_swagger_ui_escape.py
Normal file
37
tests/test_swagger_ui_escape.py
Normal 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
62
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user