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.
69 lines
2.3 KiB
Python
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
|