fix(security): enforce a strict nonce-based Content-Security-Policy (F-008) - #10
Merged
Merged
Conversation
The backend only serves JSON (plus the unused Laravel welcome view and health check), so default-src 'none' plus base-uri/frame-ancestors/ form-action 'none' close the CSP gap from F-008 without allowing any script, style, image, or connection source. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asserts the JSON API sends the restrictive CSP with no unsafe-inline/ unsafe-eval, and that HTML responses keep all five hardening headers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Derive script/style/img/connect/frame directives from the same runtime config source used for /runtime-config.js (COMMANDSPHERE_API_PUBLIC_URL, COMMANDSPHERE_REVERB_PUBLIC_*), never from a hardcoded host. A fresh style-src nonce is generated per request, passed to Angular via CSP_NONCE and stamped on <app-root ngCspNonce> so lazy-route styles Angular injects at runtime carry it too; nonce'd responses are marked Cache-Control: no-store since the nonce cannot be reused across requests. Disables Angular's critical-CSS inlining in both the build (angular.json optimization.styles.inlineCritical) and CommonEngine.render (inlineCriticalCss), which otherwise emits an inline <style> plus a <link onload="..."> attribute that would require unsafe-inline in style-src-elem/script-src-attr. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CSP's style-src (no unsafe-inline, no style-src-attr override) also governs inline style="" attributes, not just <style> blocks. Verified with Playwright that Angular's [style.width.px]/[style.width] bindings on UiIconComponent and UiSkeletonComponent serialize into a literal style attribute in SSR HTML, which the browser then blocks. - UiIconComponent/UiSkeletonComponent: every call site passes a literal, enumerable size, so sizing now resolves to static Tailwind arbitrary-value classes instead of an inline style. - AnalyticsPageComponent: the per-command bar width is a continuous, data-derived percentage that cannot be enumerated into static classes, so the bar is now an inline SVG whose fill <rect> uses the `width` geometry attribute (not a CSS style) via [attr.width]. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unit tests for buildContentSecurityPolicy/resolveRuntimeBrowserConfig (no unsafe-eval/wildcard, no unsafe-inline in style-src, connect-src tracks COMMANDSPHERE_API_PUBLIC_URL and the Reverb scheme/host/port), plus regression tests proving UiIconComponent/UiSkeletonComponent never render a style="" attribute. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a dedicated Playwright spec asserting the login page response carries the strict, applied CSP (no unsafe-inline/unsafe-eval), and that hydration, the command palette, search, the command detail page, the plugin document viewer, and the admin plugins/ingestions pages raise no securitypolicyviolation event end to end. Run against the production SSR build (node dist/frontend/server) proxied to the dev backend, since docker-compose.yml serves the frontend via `ng serve` (no CSP at all) and docker-compose.prod.yml disables dev-login by design (APP_ENV=production). NODE.md left as a delivery note, not committed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…protected express.static (mounted before renderAngular) was serving every prerendered .html file (/index.html, /search/index.html, /login/index.html, /index.csr.html, ...) as an inert static asset: no Content-Security-Policy, no nonce, and Cache-Control: public, max-age=31536000. These are full, hydratable documents that bootstrap <app-root> and load main-*.js, and docker-compose.prod.yml publishes this Express server directly with no reverse proxy in front, so every one of them was reachable in production with inline script/onerror execution unblocked. .html requests now bypass express.static and fall through to renderAngular, which normalizes the prerendered file path back to its SPA route (normalizePrerenderedHtmlPath) and applies the same per-request CSP, nonce, and Cache-Control: no-store as every other page. /runtime-config.js also gets an explicit Content-Security-Policy header. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unit: normalizePrerenderedHtmlPath maps every prerendered file path (/index.html, /index.csr.html, /admin/ingestions/index.html, with and without a query string) back to its SPA route, and leaves non-.html paths untouched. E2E: every path the audit flagged (/index.html, /search/index.html, /login/index.html, /analytics/index.html, /favorites/index.html, /admin/plugins/index.html, /admin/ingestions/index.html, /index.csr.html) plus /runtime-config.js now returns the strict CSP with Cache-Control: no-store, and an inline <script>/<img onerror> injected into /index.html or /index.csr.html is blocked and reported as a securitypolicyviolation instead of executing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
req.path.endsWith('.html') is case-sensitive, but Windows' filesystem is
not: express.static there still resolves and serves the on-disk
index.html for a request to /INDEX.HTML or /Index.Html, bypassing the
renderAngular detour and shipping those paths with no CSP and a
one-year Cache-Control, exactly like before the previous fix. Linux
(where production runs) is case-sensitive and was never affected, but
the check is now case-insensitive everywhere so behavior does not
depend on the host OS.
isPrerenderedHtmlRequestPath and normalizePrerenderedHtmlPath in
prerendered-html.ts both match .html/index.html/index.csr.html
case-insensitively; lowercase paths behave exactly as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Register the strict per-request-nonce CSP as ADR-30 and BRAIN-009, mark F-008 as mitigated in the system audit, route ADR-30 from the context index, and add the C3 handoff. Update the backlog: drop the CSP item and the stale composer advisory count (composer audit reports none), and track the Pest gate wiping the development database, the pre-existing E2E locale mismatch, the non-deterministic Pest assertion total, X-Request-Id on unrouted API responses, and Jest loading e2e specs on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes audit finding F-008: neither the Laravel API nor the SSR Node server sent
Content-Security-Policy.Policy
SSR (HTML, per request):
Plus
Cache-Control: no-store. Nounsafe-inline, nounsafe-eval, no wildcard inscript-srcorconnect-src.connect-srcis built by the same function that serves/runtime-config.js, so it followsCOMMANDSPHERE_API_PUBLIC_URLandCOMMANDSPHERE_REVERB_PUBLIC_*.API (JSON):
default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none', from global middleware, so error responses are covered too.What it took
app-rootasngCspNonce.angular.jsonand inCommonEngine.render, because it emitted inlineonloadhandlers.[style.*]bindings replaced: static classes inUiIconComponentandUiSkeletonComponent, an SVGrectwidth for the analytics bar.*.htmlrequests, matched case-insensitively, go through the render instead ofexpress.static, which had served them raw with a one-year cache.Validation
Independent adversarial audit, approved on the final rebased state (base
ec2ddfa):/runtime-config.js, and API 200/401/404/405/422 responses.<script>and<img onerror>are blocked and reported. Pages hydrate with zero violations.composer auditclean ·npm audit0 critical.The one E2E failure is pre-existing and unrelated:
portfolio-happy-paths.spec.tsassertsRun #while the pt-BR UI rendersExecução #. It is tracked in the brain backlog.Docs
ADR-30 in
docs/DECISIONS.md, BRAIN-009, F-008 marked mitigated, and a handoff inbrain/handoffs/.🤖 Generated with Claude Code