From b40b7c2e3e001f7f3f2e494c58cca3e003daa0dd Mon Sep 17 00:00:00 2001 From: dev-luigi <70869541+dev-luigi@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:25:21 +0000 Subject: [PATCH 1/9] fix(sec-01): contain the SPA catch-all against pre-auth path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-all is the only pre-auth request handler in the app, so an escaping path there is an unauthenticated arbitrary-file read (F1, head of Chain A — it is how an attacker obtains data/ipmideck.db and data/encryption.key). Add a module-level pure _resolve_spa_file(full_path, root) that canonicalises with resolve() and requires is_relative_to(root) + is_file(), so containment does not depend on how the escape is spelled. Empty/over-long paths and an embedded NUL return None (Path.resolve() raises ValueError on a NUL byte, and an over-long/UNC-shaped path can stall resolution on a network lookup); a None result means "serve index.html", so no hostile path becomes a 500. The unmatched-api/ 404 branch is retained and kept FIRST — it is the FIX-04 disabled-module contract and the obvious form of this patch drops it. Verified against a live uvicorn over a raw socket: the three spellings that leaked pyproject.toml at 4322 bytes now all return the 647-byte index.html; /api/nonexistent-xyz and /api/modules/disabled/foo still 404; /%00x is 200, not 500. The SPA still boots in chromium with a mounted root and no page errors. The 58-route contract snapshot is unchanged. --- backend/main.py | 40 ++- tests/integration/test_live_attack_chains.py | 304 +++++++++++++++++++ tests/unit/test_spa_containment.py | 100 ++++++ 3 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_live_attack_chains.py create mode 100644 tests/unit/test_spa_containment.py diff --git a/backend/main.py b/backend/main.py index 89dd1d8..d9bac77 100644 --- a/backend/main.py +++ b/backend/main.py @@ -391,6 +391,34 @@ async def websocket_endpoint( app.include_router(module_router, prefix="/api/admin/modules", tags=["Modules"], dependencies=[Depends(require_auth)]) +def _resolve_spa_file(full_path: str, root: Path) -> Path | None: + """Resolve a SPA request path to a contained file under ``root``. + + SEC-01 (F1): the catch-all is the only pre-auth request handler in the app, + so an escaping path here is an unauthenticated arbitrary-file read. Returns + the canonicalised file when it is BOTH inside ``root`` and an existing file; + returns None in every other case, meaning "fall back to index.html". + + None is returned (rather than raising) so the handler never leaks the reason + a path was refused, and never turns a hostile path into a 500: + ``Path.resolve()`` raises ValueError on an embedded NUL byte, and an + over-long / UNC-shaped path can stall resolution on a network lookup. + """ + if not full_path or len(full_path) > 1024: + return None + if "\x00" in full_path: + return None + try: + candidate = (root / full_path).resolve() + except (ValueError, OSError): + return None + if not candidate.is_relative_to(root): + return None + if not candidate.is_file(): + return None + return candidate + + def _mount_spa(app: FastAPI) -> None: """Register static file serving and SPA fallback route. @@ -412,6 +440,8 @@ def _mount_spa(app: FastAPI) -> None: except Exception: pass # Already mounted (e.g., during --reload; ignore duplicate) + spa_root = static_dir.resolve() + # SPA fallback: non-API routes return index.html for React Router. # API paths (/api/*) that don't match a registered route return 404 — # this is critical for FIX-04: disabled modules must return 404, not 200. @@ -420,15 +450,17 @@ async def serve_spa(full_path: str): from fastapi import HTTPException # Reject unmatched /api/* paths so disabled modules return 404 (not SPA). + # MUST stay first: this is the FIX-04 disabled-module contract. if full_path.startswith("api/"): raise HTTPException(status_code=404, detail="Not found") - # Try to serve the exact file first (favicon.svg, etc.) - file_path = static_dir / full_path - if full_path and file_path.is_file(): + # Try to serve the exact file first (favicon.svg, etc.), but only when + # it is contained under the SPA root (SEC-01 / F1). + file_path = _resolve_spa_file(full_path, spa_root) + if file_path is not None: return FileResponse(file_path) # Otherwise return index.html for React Router - return FileResponse(static_dir / "index.html") + return FileResponse(spa_root / "index.html") # === CLI entry point === diff --git a/tests/integration/test_live_attack_chains.py b/tests/integration/test_live_attack_chains.py new file mode 100644 index 0000000..aded842 --- /dev/null +++ b/tests/integration/test_live_attack_chains.py @@ -0,0 +1,304 @@ +"""Live attack-chain replay — a REAL server, raw HTTP over a socket. + +This is the empirical proof Phase 10 is judged on. It boots `backend.main:app` +under uvicorn as a subprocess (temp data dir, demo mode) and speaks raw HTTP so +percent-encoded escapes arrive at the handler exactly as an attacker sends +them — no client library normalises them away first. + +Covers: + * SEC-01 / F1 — pre-auth path traversal in the SPA catch-all. + * SEC-07 / F11 — `GET /api/system/app-config/{key}` serving the session secret. + +Deliberately does NOT use the sync `client` / `client_auth` conftest fixtures: +they drive their own event loop, and this test owns a real process instead. +""" + +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BOOT_TIMEOUT_S = 30.0 + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _raw_request(port: int, raw_target: str, headers: str = "") -> tuple[int, bytes]: + """Send a hand-built request line so the target is NOT normalised. + + Returns (status_code, body_bytes). + """ + with socket.create_connection(("127.0.0.1", port), timeout=10) as sock: + request = ( + f"GET {raw_target} HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\n" + f"{headers}" + "Connection: close\r\n\r\n" + ) + sock.sendall(request.encode("latin-1")) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + break + chunks.append(chunk) + payload = b"".join(chunks) + head, _, body = payload.partition(b"\r\n\r\n") + status = int(head.split(b"\r\n")[0].split(b" ")[1]) + return status, body + + +@pytest.fixture(scope="module") +def live_server(tmp_path_factory: pytest.TempPathFactory): + """Boot a real uvicorn subprocess on an ephemeral port; always tear it down.""" + data_dir = tmp_path_factory.mktemp("live-server-data") + port = _free_port() + env = { + "PATH": "/usr/bin:/bin:/usr/local/bin", + "HOME": str(data_dir), + "IPMIDECK_DEMO": "true", + "IPMIDECK_DATA_DIR": str(data_dir), + "IPMIDECK_DATA_DB_PATH": str(data_dir / "test.db"), + "IPMIDECK_LOGGING_LEVEL": "warning", + "PYTHONPATH": str(REPO_ROOT), + "NO_COLOR": "1", + "TERM": "dumb", + } + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "backend.main:app", + "--host", + "127.0.0.1", + "--port", + str(port), + "--log-level", + "warning", + ], + cwd=str(REPO_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + try: + deadline = time.monotonic() + BOOT_TIMEOUT_S + ready = False + while time.monotonic() < deadline: + if proc.poll() is not None: + out = proc.stdout.read().decode(errors="replace") if proc.stdout else "" + pytest.fail(f"server exited during boot (rc={proc.returncode}):\n{out}") + try: + status, _ = _raw_request(port, "/api/health") + if status == 200: + ready = True + break + except OSError: + pass + time.sleep(0.25) + if not ready: + pytest.fail(f"server did not answer /api/health within {BOOT_TIMEOUT_S}s") + yield port + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover - defensive + proc.kill() + proc.wait(timeout=10) + if proc.stdout: + proc.stdout.close() + + +@pytest.fixture(scope="module") +def index_html_size(live_server: int) -> int: + """The byte length of the SPA shell — the "contained" answer for every escape.""" + status, body = _raw_request(live_server, "/") + assert status == 200 + assert len(body) > 0 + return len(body) + + +# --- SEC-01 / F1 — pre-auth path traversal -------------------------------- + +# The first three were verified EXPLOITABLE against this handler before the fix: +# each returned 200 with the 4322-byte pyproject.toml where index.html is 647 bytes. +TRAVERSAL_TARGETS = [ + "/../../pyproject.toml", + "/%2e%2e%2f%2e%2e%2fpyproject.toml", + "/..%2f..%2fpyproject.toml", + "/..%5c..%5cpyproject.toml", + "/....//....//pyproject.toml", + "/%2e%2e/%2e%2e/pyproject.toml", + "/../../backend/main.py", + "/../../data/ipmideck.db", + "/../../data/encryption.key", + "/etc/passwd", + "/../../../../../../etc/passwd", +] + + +@pytest.mark.parametrize("target", TRAVERSAL_TARGETS) +def test_traversal_returns_spa_not_the_target_file( + live_server: int, index_html_size: int, target: str +) -> None: + """Every escaping spelling must return the SPA shell, byte-for-byte.""" + status, body = _raw_request(live_server, target) + assert status == 200, f"{target} -> HTTP {status}" + assert len(body) == index_html_size, ( + f"{target} returned {len(body)} bytes, expected the {index_html_size}-byte " + f"index.html — content leaked outside the web root" + ) + + +@pytest.mark.parametrize("target", TRAVERSAL_TARGETS) +def test_traversal_body_contains_no_out_of_root_content(live_server: int, target: str) -> None: + """Belt-and-braces: no fingerprint of a real out-of-root file in the body.""" + _, body = _raw_request(live_server, target) + lowered = body.lower() + for marker in (b"[project]", b"[tool.", b"root:x:", b"def _mount_spa", b"sqlite format"): + assert marker not in lowered, f"{target} leaked {marker!r}" + + +def test_unmatched_api_path_still_404s(live_server: int) -> None: + """FIX-04 disabled-module contract: unmatched /api/* is 404, never the SPA.""" + for target in ("/api/nonexistent-xyz", "/api/modules/disabled/foo", "/api/"): + status, _ = _raw_request(live_server, target) + assert status == 404, f"{target} -> HTTP {status}, expected 404" + + +def test_nul_byte_path_does_not_500(live_server: int, index_html_size: int) -> None: + """Path.resolve() raises ValueError on a NUL byte — it must be caught.""" + status, body = _raw_request(live_server, "/%00x") + assert status == 200 + assert len(body) == index_html_size + + +def test_over_long_path_does_not_500(live_server: int, index_html_size: int) -> None: + status, body = _raw_request(live_server, "/" + "a" * 5000) + assert status == 200 + assert len(body) == index_html_size + + +def test_root_serves_the_spa(live_server: int, index_html_size: int) -> None: + status, body = _raw_request(live_server, "/") + assert status == 200 + assert b"