From: Sebastián Ramírez Date: Mon, 29 Jun 2026 11:57:59 +0000 (+0200) Subject: ♻️ Make `app.frontend()` return 404 for methods other than `GET` or `HEAD` with no... X-Git-Tag: 0.138.2~2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=b790e14cb686506df663959ac4879053cfed38db;p=thirdparty%2Ffastapi%2Ffastapi.git ♻️ Make `app.frontend()` return 404 for methods other than `GET` or `HEAD` with no static file matches (#15863) --- diff --git a/docs/en/docs/tutorial/frontend.md b/docs/en/docs/tutorial/frontend.md index 9f0bc0566b..4cbc21fa13 100644 --- a/docs/en/docs/tutorial/frontend.md +++ b/docs/en/docs/tutorial/frontend.md @@ -52,7 +52,9 @@ For that, use `fallback="index.html"`: {* ../../docs_src/frontend/tutorial002_py310.py hl[5] *} -**FastAPI** uses this fallback only for requests that look like browser navigation. Missing files like JavaScript, CSS, and images still return `404`. +**FastAPI** uses this fallback only for `GET` and `HEAD` requests that look like browser navigation. Missing files like JavaScript, CSS, and images still return `404`. + +Requests with other methods, like `POST` or `PUT`, to paths that only match the frontend fallback also return `404`. Regular **FastAPI** *path operations* still have higher priority than frontend routes. /// tip diff --git a/fastapi/routing.py b/fastapi/routing.py index 3a2d75422f..e41ef6a599 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1841,34 +1841,19 @@ class _FrontendStaticFiles(StaticFiles): async def get_response(self, path: str, scope: Scope) -> Response: if scope["method"] not in ("GET", "HEAD"): - raise HTTPException(status_code=405) - - try: - full_path, stat_result = await run_in_threadpool(self.lookup_path, path) - except PermissionError: - raise HTTPException(status_code=401) from None - except OSError as exc: - if exc.errno == errno.ENAMETOOLONG: - raise HTTPException(status_code=404) from None - raise exc - except ValueError: - raise HTTPException(status_code=404) from None + if await self._lookup_static_resource(path) is not None: + raise HTTPException(status_code=405) + raise HTTPException(status_code=404) - if stat_result and stat.S_ISREG(stat_result.st_mode): + static_resource = await self._lookup_static_resource(path) + if static_resource is not None: + full_path, stat_result, is_directory_index = static_resource + if is_directory_index and not scope["path"].endswith("/"): + url = URL(scope=scope) + url = url.replace(path=url.path + "/") + return RedirectResponse(url=url) return self.file_response(full_path, stat_result, scope) - if stat_result and stat.S_ISDIR(stat_result.st_mode): - index_path = os.path.join(path, "index.html") - full_path, stat_result = await run_in_threadpool( - self.lookup_path, index_path - ) - if stat_result is not None and stat.S_ISREG(stat_result.st_mode): - if not scope["path"].endswith("/"): - url = URL(scope=scope) - url = url.replace(path=url.path + "/") - return RedirectResponse(url=url) - return self.file_response(full_path, stat_result, scope) - if self.fallback == "404.html" or ( self.fallback == "auto" and self._fallback_file_exists("404.html") ): @@ -1882,6 +1867,33 @@ class _FrontendStaticFiles(StaticFiles): raise HTTPException(status_code=404) + async def _lookup_path(self, path: str) -> tuple[str, os.stat_result | None]: + try: + return await run_in_threadpool(self.lookup_path, path) + except PermissionError: + raise HTTPException(status_code=401) from None + except OSError as exc: + if exc.errno == errno.ENAMETOOLONG: + raise HTTPException(status_code=404) from None + raise exc + except ValueError: + raise HTTPException(status_code=404) from None + + async def _lookup_static_resource( + self, path: str + ) -> tuple[str, os.stat_result, bool] | None: + full_path, stat_result = await self._lookup_path(path) + if stat_result is None: + return None + if stat.S_ISREG(stat_result.st_mode): + return full_path, stat_result, False + if stat.S_ISDIR(stat_result.st_mode): + index_path = os.path.join(path, "index.html") + full_path, stat_result = await self._lookup_path(index_path) + if stat_result is not None and stat.S_ISREG(stat_result.st_mode): + return full_path, stat_result, True + return None + def _fallback_file_exists(self, fallback: str) -> bool: _, stat_result = self.lookup_path(fallback) return stat_result is not None and stat.S_ISREG(stat_result.st_mode) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 12be8eaf2b..81cffc2285 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -2,6 +2,7 @@ import errno import os import runpy from pathlib import Path +from typing import Literal import anyio import pytest @@ -639,6 +640,21 @@ def test_head_requests_work(tmp_path: Path): assert response.headers["content-length"] == "2" +def test_head_fallback_request_works(tmp_path: Path): + dist = tmp_path / "dist" + write_file(dist / "index.html", "app shell") + app = FastAPI() + app.frontend("/", directory=dist, fallback="index.html") + + response = TestClient(app).head( + "/dashboard/settings", headers={"accept": "text/html"} + ) + + assert response.status_code == 200 + assert response.text == "" + assert response.headers["content-length"] == "9" + + def test_unsupported_methods_return_405(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "asset.txt", "ok") @@ -650,6 +666,125 @@ def test_unsupported_methods_return_405(tmp_path: Path): assert response.status_code == 405 +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) +def test_unsupported_methods_to_fallback_only_routes_return_404( + tmp_path: Path, method: str +): + dist = tmp_path / "dist" + write_file(dist / "index.html", "app shell") + app = FastAPI() + app.frontend("/", directory=dist, fallback="index.html") + + response = TestClient(app).request( + method, "/dashboard/settings", headers={"accept": "text/html"} + ) + + assert response.status_code == 404 + + +def test_unsupported_methods_to_frontend_root_and_directory_index_return_405( + tmp_path: Path, +): + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + write_file(dist / "about" / "index.html", "about") + app = FastAPI() + app.frontend("/", directory=dist) + client = TestClient(app) + + root_response = client.post("/") + directory_response = client.post("/about/") + + assert root_response.status_code == 405 + assert directory_response.status_code == 405 + + +def test_unsupported_method_to_directory_without_index_returns_404(tmp_path: Path): + dist = tmp_path / "dist" + (dist / "empty").mkdir(parents=True) + write_file(dist / "index.html", "app") + app = FastAPI() + app.frontend("/", directory=dist) + + response = TestClient(app).post("/empty/") + + assert response.status_code == 404 + + +def test_unsupported_methods_to_fallback_only_routes_ignore_accept( + tmp_path: Path, +): + dist = tmp_path / "dist" + write_file(dist / "index.html", "app shell") + app = FastAPI() + app.frontend("/", directory=dist, fallback="index.html") + + response = TestClient(app).post( + "/dashboard/settings", headers={"accept": "application/json"} + ) + + assert response.status_code == 404 + + +@pytest.mark.parametrize( + ("fallback", "files"), + [ + ("404.html", {"404.html": "missing"}), + ("auto", {"index.html": "app shell"}), + (None, {"index.html": "app shell"}), + ], +) +def test_unsupported_methods_to_fallback_only_routes_return_404_for_fallback_modes( + tmp_path: Path, + fallback: Literal["auto", "index.html", "404.html"] | None, + files: dict[str, str], +): + dist = tmp_path / "dist" + for file, content in files.items(): + write_file(dist / file, content) + app = FastAPI() + app.frontend("/", directory=dist, fallback=fallback) + + response = TestClient(app).post( + "/dashboard/settings", headers={"accept": "text/html"} + ) + + assert response.status_code == 404 + + +def test_apirouter_frontend_unsupported_method_to_fallback_only_route_returns_404( + tmp_path: Path, +): + dist = tmp_path / "dist" + write_file(dist / "index.html", "admin") + router = APIRouter() + router.frontend("/", directory=dist, fallback="index.html") + app = FastAPI() + app.include_router(router, prefix="/admin") + + response = TestClient(app).post( + "/admin/client-route", headers={"accept": "text/html"} + ) + + assert response.status_code == 404 + + +def test_unsupported_method_uses_longest_matching_frontend_prefix(tmp_path: Path): + site = tmp_path / "site" + admin = tmp_path / "admin" + write_file(site / "admin" / "client-route", "site asset") + write_file(admin / "index.html", "admin") + app = FastAPI() + app.frontend("/", directory=site) + app.frontend("/admin", directory=admin, fallback="index.html") + + response = TestClient(app).post( + "/admin/client-route", headers={"accept": "text/html"} + ) + + assert response.status_code == 404 + + @pytest.mark.parametrize( "path", [