Files
Ten31-Portal/backend/tests/test_static_traversal.py
T
Jonathan Kirkwood 3c7094241c 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.
2026-08-10 15:38:21 -05:00

69 lines
2.3 KiB
Python

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