phase: 01_runnable_service_scaffold
**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
"""PLAN A12 guard: the browser must load nothing this process does not serve.
|
||||
|
||||
This is the mechanical half of "no external CDNs" (LOCKED decision A12, AGENTS.md §5).
|
||||
It has two halves on purpose:
|
||||
|
||||
1. **A text scan** of every `app/static/**` asset (`.html`, `.css`, `.js`, `.svg`) for the
|
||||
shapes that reach off-origin: `http(s)://`, protocol-relative `//host/`, hosted-library
|
||||
mirrors (`jsdelivr`, `unpkg`, …), CSS `@import url(`, protocol-relative stylesheets,
|
||||
`srcset`, `fetch()` / `import` of an absolute URL, an absolute-URL `WebSocket`, and
|
||||
`EventSource` (SSE is not this project's transport). A single remote reference is both
|
||||
an availability and a privacy defect, so the scan is blunt and needs no allow-list —
|
||||
with one documented exception, the `xmlns` namespace URIs of SVG/XML, which are
|
||||
identifiers the browser never fetches (A11 ships in-repo SVG art).
|
||||
|
||||
2. **An element check** of `index.html`: every `<script src>`, `<link href>` and
|
||||
`<img src>` must be a same-origin `/static/...` path (a `data:` URL is allowed where
|
||||
the CSP allows it — `img-src 'self' data:` — because it is the app's own bytes).
|
||||
|
||||
The root it scans is imported from `undercooked.server.app.STATIC_DIR`, i.e. the very
|
||||
directory the server mounts at `/static`, so the guard cannot pass by scanning some other
|
||||
folder. The last group of tests plants each forbidden shape in a temporary tree and
|
||||
asserts the scan names the offending file, which is what keeps this file from rotting
|
||||
into a test that scans nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.app import STATIC_DIR, resolve_static_dir
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: Every file type a browser can be told to load or parse.
|
||||
ASSET_SUFFIXES = frozenset({".html", ".css", ".js", ".svg"})
|
||||
|
||||
#: Files whose text may name a hosted library. CSS/SVG are covered by the URL rules below;
|
||||
#: HTML and JS are where a `<script>`/`import` of a hosted bundle actually shows up.
|
||||
HOSTED_TOKEN_SUFFIXES = frozenset({".html", ".js"})
|
||||
|
||||
#: Hosted-library / hosted-font mirrors that must never appear in shipped client text.
|
||||
HOSTED_LIBRARY_TOKENS = ("googleapis", "gstatic", "jsdelivr", "unpkg", "cdnjs", "tailwindcss")
|
||||
|
||||
#: An absolute or protocol-relative authority in a URL-ish position.
|
||||
_ABS = r"(?:https?:)?//"
|
||||
|
||||
#: XML namespace declarations: identifiers, not requests (SVG art is first-class, A11).
|
||||
_NAMESPACE_DECL = re.compile(
|
||||
r"""xmlns(?::[\w.-]+)?\s*=\s*["']?https?://www\.w3\.org/[^\s"'<>]*["']?""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: (rule name, pattern) pairs applied to each masked line of every asset.
|
||||
BANNED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
(
|
||||
"absolute URL",
|
||||
re.compile(r"https?://", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"protocol-relative URL",
|
||||
# `//host.tld/...` — the scheme is deliberately NOT part of this pattern, so the
|
||||
# lookbehind really does keep `https://` from being reported twice, and the
|
||||
# host-shaped lookahead keeps JS/CSS comments (`// note`) from matching at all.
|
||||
re.compile(r"(?<![A-Za-z0-9:])//(?![\s/])(?:[\w-]+\.)+[a-z]{2,}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"CSS @import url()",
|
||||
re.compile(r"@import\s+url\s*\(", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"protocol-relative stylesheet",
|
||||
re.compile(
|
||||
rf"""<link\b[^>]*?rel=["']?[^"'<>]*stylesheet[^>"']*["']?[^>]*?href=["']?{_ABS}""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
(
|
||||
"srcset attribute",
|
||||
re.compile(r"\bsrcset\s*=", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"fetch() of an off-origin URL",
|
||||
re.compile(rf"\bfetch\s*\(\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"import of an off-origin URL",
|
||||
re.compile(rf"\b(?:import|export)\b[^;\n]*?\bfrom\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"bare import of an off-origin URL",
|
||||
re.compile(rf"\bimport\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"WebSocket with an absolute URL",
|
||||
# Same-origin is built from `location`, never written out (`WebSocket("ws…`).
|
||||
re.compile(r"\bWebSocket\s*\(\s*[\"'`](?:wss?|https?)://", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"EventSource (server-sent events)",
|
||||
re.compile(r"\bnew\s+EventSource\s*\(|\bEventSource\s*\(", re.IGNORECASE),
|
||||
),
|
||||
)
|
||||
|
||||
#: Same-origin prefixes an element URL may start with.
|
||||
STATIC_PREFIX = "/static/"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Violation:
|
||||
"""One forbidden reference, in one file, on one line."""
|
||||
|
||||
path: Path
|
||||
line: int
|
||||
rule: str
|
||||
text: str
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Human-readable failure line naming the offending file."""
|
||||
return f"{self.path}:{self.line}: {self.rule}: {self.text.strip()}"
|
||||
|
||||
|
||||
def _mask(text: str) -> str:
|
||||
"""Blank out the text that looks external but cannot cause a request."""
|
||||
return _NAMESPACE_DECL.sub(lambda match: " " * len(match.group(0)), text)
|
||||
|
||||
|
||||
def iter_asset_files(root: Path) -> Iterator[Path]:
|
||||
"""Every scannable asset under ``root``, depth-first and in a stable order."""
|
||||
if not root.is_dir(): # pragma: no cover - guarded by the caller's own assertion
|
||||
return
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and path.suffix.lower() in ASSET_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def scan_text(path: Path, text: str) -> list[Violation]:
|
||||
"""Every forbidden reference in ``text``, attributed to ``path``."""
|
||||
violations: list[Violation] = []
|
||||
for number, raw in enumerate(_mask(text).splitlines(), start=1):
|
||||
for rule, pattern in BANNED_PATTERNS:
|
||||
if pattern.search(raw):
|
||||
violations.append(Violation(path=path, line=number, rule=rule, text=raw))
|
||||
if path.suffix.lower() in HOSTED_TOKEN_SUFFIXES:
|
||||
lowered = raw.lower()
|
||||
violations.extend(
|
||||
Violation(path=path, line=number, rule=f"hosted library {token!r}", text=raw)
|
||||
for token in HOSTED_LIBRARY_TOKENS
|
||||
if token in lowered
|
||||
)
|
||||
if path.suffix.lower() == ".html":
|
||||
violations.extend(scan_element_urls(path, text))
|
||||
return violations
|
||||
|
||||
|
||||
def scan_file(path: Path) -> list[Violation]:
|
||||
"""Read one asset and return its forbidden references."""
|
||||
return scan_text(path, path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def scan_tree(root: Path) -> list[Violation]:
|
||||
"""Scan every asset under ``root`` (the shipped client, when called with STATIC_DIR)."""
|
||||
return [violation for path in iter_asset_files(root) for violation in scan_file(path)]
|
||||
|
||||
|
||||
def _element_attributes(tag: str) -> dict[str, str]:
|
||||
"""Parse `name="value"` pairs of one tag into a lower-cased mapping."""
|
||||
found: dict[str, str] = {}
|
||||
for name, value in re.findall(r"""([\w-]+)\s*=\s*["']([^"']*)["']""", tag):
|
||||
found[name.lower()] = value
|
||||
return found
|
||||
|
||||
|
||||
def _is_allowed(value: str, *, data_ok: bool) -> bool:
|
||||
if value.startswith(STATIC_PREFIX):
|
||||
return True
|
||||
return data_ok and value.startswith("data:")
|
||||
|
||||
|
||||
def scan_element_urls(path: Path, text: str) -> list[Violation]:
|
||||
"""Every `<script src>` / `<link href>` / `<img src>` in an HTML document that this
|
||||
app does not serve.
|
||||
|
||||
`<script src>` and stylesheet `<link>`s must be `/static/...`. `<img>` and
|
||||
non-stylesheet `<link>`s (the `data:` favicon) may additionally be `data:`, matching
|
||||
the CSP's `img-src 'self' data:` — in-app bytes either way.
|
||||
"""
|
||||
violations: list[Violation] = []
|
||||
for match in re.finditer(r"<(script|link|img)\b((?:[^>'\"]|\"[^\"]*\"|'[^']*')*)>", text, re.I):
|
||||
name = match.group(1).lower()
|
||||
attrs = _element_attributes(match.group(2))
|
||||
if name == "script":
|
||||
attribute, data_ok = "src", False
|
||||
elif name == "link":
|
||||
attribute, data_ok = "href", "stylesheet" not in attrs.get("rel", "").lower()
|
||||
else:
|
||||
attribute, data_ok = "src", True
|
||||
value = attrs.get(attribute)
|
||||
if value is None or _is_allowed(value, data_ok=data_ok):
|
||||
continue
|
||||
line = text.count("\n", 0, match.start()) + 1
|
||||
rule = f"<{name} {attribute}> is not a same-origin /static/ path"
|
||||
violations.append(Violation(path=path, line=line, rule=rule, text=match.group(0)))
|
||||
return violations
|
||||
|
||||
|
||||
def format_violations(violations: list[Violation]) -> str:
|
||||
"""Render violations for a pytest failure message."""
|
||||
return "\n".join(violation.describe() for violation in violations)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_tree(tmp_path: Path) -> Path:
|
||||
"""A throwaway static root, shaped like the real one, for planted violations."""
|
||||
root = tmp_path / "static"
|
||||
(root / "js").mkdir(parents=True)
|
||||
(root / "css").mkdir(parents=True)
|
||||
(root / "index.html").write_text("<!doctype html><title>t</title>", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the scanned root
|
||||
|
||||
|
||||
def test_the_guard_scans_the_directory_the_server_actually_serves() -> None:
|
||||
"""Re-deriving the root here is what stops a green run against the wrong folder."""
|
||||
assert STATIC_DIR == REPO_ROOT / "app" / "static"
|
||||
assert STATIC_DIR.is_dir(), f"{STATIC_DIR} is not a directory"
|
||||
assert (STATIC_DIR / "index.html").is_file()
|
||||
assert resolve_static_dir(Settings()) == STATIC_DIR
|
||||
|
||||
|
||||
def test_the_scan_walks_the_shipped_client_files() -> None:
|
||||
names = {path.relative_to(STATIC_DIR).as_posix() for path in iter_asset_files(STATIC_DIR)}
|
||||
assert {"index.html", "js/app.js", "css/base.css"} <= names, names
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the shipped client
|
||||
|
||||
|
||||
def test_shipped_client_references_nothing_external() -> None:
|
||||
"""A12: zero forbidden references anywhere under app/static/."""
|
||||
violations = scan_tree(STATIC_DIR)
|
||||
report = format_violations(violations)
|
||||
assert violations == [], f"the client loads bytes this app does not serve:\n{report}"
|
||||
|
||||
|
||||
def test_shipped_index_html_loads_only_same_origin_assets() -> None:
|
||||
"""Non-vacuous on purpose: the index must actually declare a script and a stylesheet."""
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
tags = re.findall(r"<(script|link|img)\b", html, re.I)
|
||||
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
|
||||
assert 'src="/static/js/app.js"' in html
|
||||
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- planted violations
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content"),
|
||||
[
|
||||
("script_src", '<!doctype html><script src="https://cdn.example.com/x.js"></script>'),
|
||||
(
|
||||
"module_src",
|
||||
'<!doctype html><script type="module" src="//cdn.example.com/x.js"></script>',
|
||||
),
|
||||
("stylesheet_link", '<link rel="stylesheet" href="https://fonts.example.com/f.css">'),
|
||||
("protocol_relative_stylesheet", '<link rel="stylesheet" href="//cdn.example.com/a.css">'),
|
||||
("img_src", '<img src="https://evil.example.com/pixel.png">'),
|
||||
("img_srcset", '<img src="/static/a.svg" srcset="https://cdn.example.com/b.svg 2x">'),
|
||||
],
|
||||
)
|
||||
def test_a_planted_html_reference_fails_the_scan(
|
||||
static_tree: Path, name: str, content: str
|
||||
) -> None:
|
||||
page = static_tree / f"{name}.html"
|
||||
page.write_text(content, encoding="utf-8")
|
||||
violations = scan_tree(static_tree)
|
||||
assert violations, content
|
||||
assert all(violation.path == page for violation in violations), format_violations(violations)
|
||||
assert page.name in format_violations(violations)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "content", "rule"),
|
||||
[
|
||||
(
|
||||
".js",
|
||||
'import { x } from "https://cdn.example.com/lib.js";',
|
||||
"import of an off-origin URL",
|
||||
),
|
||||
(
|
||||
".js",
|
||||
'import "https://cdn.example.com/side-effect.js";',
|
||||
"bare import of an off-origin URL",
|
||||
),
|
||||
(".js", 'export * from "//cdn.example.com/lib.js";', "import of an off-origin URL"),
|
||||
(".js", 'fetch("https://api.example.com/state");', "fetch() of an off-origin URL"),
|
||||
(".js", 'fetch("//api.example.com/state");', "fetch() of an off-origin URL"),
|
||||
(
|
||||
".js",
|
||||
'new WebSocket("wss://remote.example.com/ws/ABCDE");',
|
||||
"WebSocket with an absolute URL",
|
||||
),
|
||||
(".js", 'new EventSource("/events");', "EventSource (server-sent events)"),
|
||||
(".js", 'const u = "https://cdn.example.com/x.js";', "absolute URL"),
|
||||
(".css", "@import url(https://fonts.example.com/one.css);", "CSS @import url()"),
|
||||
(".css", "@import url(//fonts.example.com/one.css);", "CSS @import url()"),
|
||||
(".css", "body { background: url(//cdn.example.com/bg.png); }", "protocol-relative URL"),
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<image href="https://x.example.com/a.png"/></svg>',
|
||||
"absolute URL",
|
||||
),
|
||||
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
|
||||
],
|
||||
)
|
||||
def test_a_planted_asset_reference_fails_the_scan(
|
||||
static_tree: Path, suffix: str, content: str, rule: str
|
||||
) -> None:
|
||||
planted = static_tree / "js" / f"planted{suffix}"
|
||||
planted.write_text(content, encoding="utf-8")
|
||||
violations = scan_tree(static_tree)
|
||||
assert violations, content
|
||||
assert rule in {violation.rule for violation in violations}, format_violations(violations)
|
||||
assert any(violation.path == planted for violation in violations)
|
||||
assert planted.name in format_violations(violations)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", HOSTED_LIBRARY_TOKENS)
|
||||
@pytest.mark.parametrize("suffix", [".js", ".html"])
|
||||
def test_a_planted_hosted_library_token_fails_the_scan(
|
||||
static_tree: Path, token: str, suffix: str
|
||||
) -> None:
|
||||
planted = static_tree / f"planted{suffix}"
|
||||
planted.write_text(f"/* vendored from {token} once */", encoding="utf-8")
|
||||
violations = [v for v in scan_tree(static_tree) if v.path == planted]
|
||||
assert violations, token
|
||||
assert token in format_violations(violations)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- what must stay green
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "content"),
|
||||
[
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
|
||||
'<rect width="16" height="16"/></svg>',
|
||||
),
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
|
||||
'<use xlink:href="#tile"/></svg>',
|
||||
),
|
||||
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
|
||||
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
|
||||
(".js", 'fetch("/static/api/levels");\n'),
|
||||
(".css", ".a { background: url(/static/assets/tile.svg); }\n"),
|
||||
(".html", '<img src="data:image/svg+xml,<svg/>">\n'),
|
||||
],
|
||||
)
|
||||
def test_same_origin_and_namespace_references_are_allowed(
|
||||
static_tree: Path, suffix: str, content: str
|
||||
) -> None:
|
||||
"""The guard must not train anyone to delete real code: same-origin stays green."""
|
||||
(static_tree / "js" / f"fine{suffix}").write_text(content, encoding="utf-8")
|
||||
assert scan_tree(static_tree) == []
|
||||
|
||||
|
||||
def test_non_asset_files_are_out_of_scope(static_tree: Path) -> None:
|
||||
"""Notes and images are not client instructions; the scan is about loadable code."""
|
||||
(static_tree / "NOTES.md").write_text("see https://example.com for details", encoding="utf-8")
|
||||
(static_tree / "logo.png").write_bytes(b"\x89PNG\r\n")
|
||||
assert scan_tree(static_tree) == []
|
||||
|
||||
|
||||
def test_an_empty_tree_is_not_a_passing_signal(tmp_path: Path) -> None:
|
||||
"""Documented explicitly: an empty root scans clean, so the root tests above matter."""
|
||||
assert scan_tree(tmp_path) == []
|
||||
assert list(iter_asset_files(tmp_path)) == []
|
||||
Reference in New Issue
Block a user