0.2.41: contain SPA static serving to the web root

Percent-encoded traversal (..%2f) survived routing and let an
unauthenticated request read files outside static/, including the
database and session secret on the data volume. Paths are now resolved
and contained to the frontend build directory; anything that escapes
falls back to index.html.
This commit is contained in:
Jonathan Kirkwood
2026-08-10 15:38:21 -05:00
parent 5d4e87e69b
commit 3c7094241c
4 changed files with 104 additions and 3 deletions
+16 -1
View File
@@ -57,6 +57,19 @@ def health() -> dict[str, str]:
return {"status": "ok"}
def _contained_static_path(static_root: Path, path: str) -> Path | None:
"""Resolve `path` under `static_root`, returning the file only if it stays
within the root. Percent-encoded traversal (..%2f) survives routing and
would otherwise let an unauthenticated caller read files outside static/
(e.g. the DB or session secret on the data volume). Returns None if the
resolved path escapes the root."""
root = static_root.resolve()
candidate = (root / path).resolve()
if candidate != root and root not in candidate.parents:
return None
return candidate
# Serve built frontend in production (when static/ dir exists next to the app)
_static_dir = Path(__file__).resolve().parent.parent / "static"
if _static_dir.is_dir():
@@ -72,7 +85,9 @@ if _static_dir.is_dir():
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
async def serve_spa(path: str):
file = _static_dir / path
file = _contained_static_path(_static_dir, path)
if file is None:
return _index()
if file.is_file():
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
if file.name == "index.html":