feat(web): serve static assets, with the headers that make the UI's reach checkable - #545
feat(web): serve static assets, with the headers that make the UI's reach checkable#545eaitbrahim wants to merge 2 commits into
Conversation
Adds keel/web/staticfiles.py, a directory-traversal-safe resolver for files under keel/web/static/ (containment via resolve()+relative_to(), not string matching -- see the module docstring for why Path's `/` operator alone is not a safe join), plus one placeholder asset so the path is exercised end to end. #536's real client does not exist yet. Wires /static/* into keel/web/server.py behind the SAME admission the rendered pages already require (host header, session cookie) -- never weakened just because today's placeholder holds no secrets. Serves it with a new header set distinct from the rendered pages' _SECURITY_HEADERS: CSP (default-src 'self'; connect-src 'self') only on text/html, per the design spec ("CSP belongs on text/html responses only; it is invalid and discouraged on other content types"); nosniff and Referrer-Policy: no-referrer unconditionally; Strict-Transport-Security deliberately never sent (loopback HTTP is not a TLS downgrade target -- reasoning recorded in a comment above the header sets so nobody adds it back). Adds a third CSRF layer: every POST now requires X-Keel-Client: 1 (a custom header forces a CORS preflight a hostile origin cannot satisfy -- the attack this closes is the HTML form POST, which is never preflighted, unlike SameSite/CSRF-token checks that assume it is), paired with a Sec-Fetch-Site: same-origin check where the header is present (missing is accepted for older browsers; a wrong value is refused, since page JS cannot forge it). Extends pyproject.toml's `artifacts` glob to keel/web/static/**, but the comment beside it records what was actually measured: the pinned uv_build (0.11.31) ships the whole module tree by default regardless of this key, so a new packaging test builds a real wheel with `uv build` and inspects the zip directly rather than trusting the glob or the source tree. All five existing layers in keel/web/security.py are untouched (0-line diff). Every prior test in tests/web/test_server.py passes unmodified -- the shared _request() helper gained optional, backward-compatible parameters instead. tests/commands/test_console_thinness.py's Rule 5 import allowlist gained one entry (staticfiles, urllib.parse) for the same no-I/O reason server.py already has one, since that rule scans the whole keel/web/ package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
|
… bundle assets
Independent review found the previous commit gated EVERY POST -- including /setup/* -- behind
X-Keel-Client. The shipped UI's only write path is render.py's plain <form method="post"
action="/setup/{key}">, and _SECURITY_HEADERS ships no script-src at all: there is no code path
by which that form can set a custom header. Every setup action (config, database, rules,
credentials) returned 403, with no terminal to fall back to on the desktop bundle. My brief said
"every state-changing POST"; the design spec says "every POST /api/*" and was right.
Fix: X-Keel-Client is now checked only under the new API_PREFIX ("/api/", reserved for #533/534's
still-unbuilt JSON API and #536's fetch()-based client). /setup/* keeps its existing defences
(host validation, SameSite=Strict cookie, HMAC CSRF token, the closed action set) plus
Sec-Fetch-Site: same-origin, which a real form POST DOES carry and which page JavaScript cannot
forge -- unlike a custom header. A new test,
test_a_browser_form_post_succeeds_without_the_api_client_header, posts to /setup/* exactly as a
browser does (cookie, CSRF token, Origin, Sec-Fetch-Site: same-origin, no X-Keel-Client) and
asserts success; verified it fails 403 against the prior commit and passes after this one.
Also, from the same review:
- The /static/* CSP was missing form-action, base-uri and frame-ancestors -- none of which
inherit from default-src under CSP3 -- plus X-Frame-Options. A hostile <form
action="https://evil.example"> was not a "connection" so connect-src 'self' did not stop it,
and a missing base-uri let an injected <base> retarget every relative URL. Now sends
default-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors
'none', plus X-Frame-Options: DENY, and the same CSP now also covers image/svg+xml (SVG is
active content: opened directly, an inline <script> inside one executes in keel's own origin).
- keel/freeze.py's collect_data (read by packaging/keel.spec, which is fully generic over that
list) did not include the web UI's static assets, so a frozen desktop bundle would 404 every
/static/* request. Added STATIC_PACKAGE = "keel.web.static" (a PEP 420 namespace package, same
shape as the existing TEMPLATE_PACKAGE) to collect_data, mirrored in a new
tests/test_freeze.py test. Not added to hiddenimports: staticfiles.py locates assets via
Path(__file__).parent, never importlib.resources, so nothing needs it as an import target.
- _serve_static bypassed _send and so emitted no Cache-Control, leaving a static asset open to
browser heuristic caching. Added Cache-Control: no-store, max-age=0 to match the rendered
pages, pending #536's versioned-asset cache strategy.
- Registered a `slow` pytest marker (new [tool.pytest.ini_options], CI passes no -m filter so
this changes nothing there) and applied it to the wheel-build packaging test, which now also
excludes dotfiles (a stray .DS_Store) from the on-disk/wheel comparison so the test cannot fail
for a reason unrelated to packaging.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
| # read (the file removed, a permission change) must be a clean 500 like any other | ||
| # broken page (`test_a_broken_page_does_not_take_the_server_down`'s guarantee), | ||
| # never an uncaught exception that leaves the connection hanging. | ||
| payload = resolved.read_bytes() |
| # See the module docstring: `root / rel` alone is not a safe join when `rel` can be | ||
| # absolute. `resolve()` then `relative_to()` is what actually enforces containment, | ||
| # regardless of how `rel` got here. | ||
| candidate = (root / rel).resolve() |
| except ValueError: | ||
| return None | ||
|
|
||
| if not candidate.is_file(): |
Reworked — the blocking defect is fixed, and verified red→greenThe write path works again
Good detail: the API gate is checked before the 404 that path currently returns, so the first API write action does not have to remember to add it.
Everything else from the review
CodeQL is red, and it is a false positive — here is the evidenceThree
I ran 14 payloads directly against the resolver, including cases the branch's own tests do not cover: I have not dismissed the alerts. That is a call on your security dashboard, not mine. The options are to dismiss all three as "used in tests / false positive" with this evidence attached, or to restructure to a shape CodeQL recognises — which would mean rewriting verified-correct security code to satisfy a scanner, and I would not recommend it. Tests pass on 3.11 and 3.14. 4610 passed, 3 skipped, ruff and mypy clean. |
Closes #535.
Adds static file serving plus the security headers the browser client will rely on. No existing security layer is modified —
keel/web/security.pyhas a 0-line diff, and no existing test body was edited.Headers, and where they go
New
/static/*route:Content-Security-Policy: default-src 'self'; connect-src 'self'text/htmlonlyX-Content-Type-Options: nosniffReferrer-Policy: no-referrerStrict-Transport-SecurityThe existing rendered pages keep their original
_SECURITY_HEADERS(default-src 'none',X-Frame-Options: DENY) untouched — correct for zero-script pages.A third CSRF layer
security.pyalready documents five layers, including aSameSite=StrictHttpOnlycookie and an HMAC-derived CSRF token described in-source as "the layer that does not depend on the browser being current." This adds one more, becauseSameSitehas had parser bypasses.Every
POSTnow requiresX-Keel-Client: 1, checked before the body is parsed. A custom header forces a CORS preflight a hostile origin cannot satisfy — and the specific attack it closes is the HTML form POST, which is not preflighted and therefore slips past checks that assume all cross-origin requests are.Sec-Fetch-Siteis refused when present and wrong (page JavaScript cannot forge it), and accepted when absent (older browsers).Directory traversal
resolve_static_asset()resolves and checks containment via.relative_to()— not string matching on"..".Tested over the real wire and at unit level:
/static/../secret.txt,../../,%2e%2e, double-encoded%252e%252e(must stay inert, not decode twice), a null byte, and/static//etc/passwd— the last being the footgun the module docstring names, sincePath("/root") / "/etc/passwd"discards the root entirely.A measured correction to the packaging story
The issue asserted the wheel omits
docs/becauseartifactslists only the YAML template. That was wrong, and I confirmed the correction independently.pyproject.toml'sartifactskey is inert on the pinned backend (uv_build>=0.10.4,<0.13.0). Building with the list and withartifacts = []produces wheels with identical contents — 140 entries each, both carrying the two YAML templates.uv_buildships the whole module root regardless. The adjacent source comment claiming "the wheel otherwise contains only .py files" is wrong by the same measurement, and is rewritten here to state what was measured.docs/is absent because it lives at the repository root, outsidekeel/. That makes #539's case stronger: the empty glossary is structural, not a missing glob.The
keel/web/static/**entry is still added — it matches prior art and stated intent, and is harmless — but the test does not trust it.test_static_assets_survive_being_built_into_a_wheelrunsuv build --wheelas a real subprocess and inspects the.whlwithzipfile. Verified red/green against the actual failure mode.One adjacent test changed, and why
test_console_thinness.py's Rule 5 scanskeel/web/*.pyand flaggedstaticfiles.py'sfrom urllib.parse import unquote. Added one entry to its existing scoped allowlist, mirroring the("server", "urllib.parse")entry already there and its rationale — pure string parsing, no I/O.The capability-disjointness test and its AST-scan sibling are untouched and green. Nothing here widens what the browser may do.
Gates
ruff check keel tests packages— All checks passedmypy— no issues in 355 source filespytest -q— 4607 passed, 3 skipped🤖 Generated with Claude Code
https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6