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:
@@ -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":
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression test for the SPA static-file path-traversal fix.
|
||||
|
||||
Before the fix, the `/{path:path}` catch-all joined the request path onto the
|
||||
static dir with no containment check, so percent-encoded traversal
|
||||
(GET /..%2f..%2fdata%2fportal.db) read arbitrary files off disk — including the
|
||||
session secret, which allowed forging an admin session. See main.py.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ten31portal.main import _contained_static_path
|
||||
|
||||
|
||||
def test_contained_helper_blocks_traversal():
|
||||
root = Path(tempfile.mkdtemp())
|
||||
static = root / "static"
|
||||
static.mkdir()
|
||||
(static / "index.html").write_text("spa")
|
||||
(static / "app.js").write_text("ok")
|
||||
data = root / "data"
|
||||
data.mkdir()
|
||||
(data / "portal.db").write_text("secret-db")
|
||||
|
||||
# Legit assets resolve within the root.
|
||||
assert _contained_static_path(static, "app.js") == (static / "app.js").resolve()
|
||||
assert _contained_static_path(static, "index.html") == (static / "index.html").resolve()
|
||||
|
||||
# Traversal (already-decoded, i.e. what ..%2f becomes) escapes -> None.
|
||||
for evil in ("../data/portal.db", "../../data/portal.db", "../data/../data/portal.db"):
|
||||
assert _contained_static_path(static, evil) is None, evil
|
||||
|
||||
|
||||
def test_spa_route_does_not_leak_via_encoded_traversal():
|
||||
"""End-to-end: encoded traversal against the real route shape returns the
|
||||
SPA shell, never the out-of-root file."""
|
||||
root = Path(tempfile.mkdtemp())
|
||||
static = root / "static"
|
||||
static.mkdir()
|
||||
(static / "index.html").write_text("<html>SPA</html>")
|
||||
data = root / "data"
|
||||
data.mkdir()
|
||||
(data / ".session-secret").write_text("TOPSECRET")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def _index():
|
||||
return FileResponse(static / "index.html")
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||
async def serve_spa(path: str):
|
||||
file = _contained_static_path(static, path)
|
||||
if file is None or not file.is_file():
|
||||
return _index()
|
||||
return FileResponse(file)
|
||||
|
||||
client = TestClient(app)
|
||||
for attack in (
|
||||
"/..%2f..%2fdata%2f.session-secret",
|
||||
"/%2e%2e%2f%2e%2e%2fdata%2f.session-secret",
|
||||
"/../../data/.session-secret",
|
||||
):
|
||||
r = client.get(attack)
|
||||
assert "TOPSECRET" not in r.text, attack
|
||||
Reference in New Issue
Block a user