Skip to content

fix(security): enforce a strict nonce-based Content-Security-Policy (F-008) - #10

Merged
LuanTrindade95 merged 10 commits into
mainfrom
fix/security-content-policy
Sep 21, 2026
Merged

LuanTrindade95 merged 10 commits into
mainfrom
fix/security-content-policy

Conversation

@LuanTrindade95

Copy link
Copy Markdown
Owner

Closes audit finding F-008: neither the Laravel API nor the SSR Node server sent Content-Security-Policy.

Policy

SSR (HTML, per request):

default-src 'self'; script-src 'self'; style-src 'self' 'nonce-<per request>';
img-src 'self' data: https:; font-src 'self';
connect-src 'self' <reverb ws origin> [<api origin when foreign>];
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

Plus Cache-Control: no-store. No unsafe-inline, no unsafe-eval, no wildcard in script-src or connect-src. connect-src is built by the same function that serves /runtime-config.js, so it follows COMMANDSPHERE_API_PUBLIC_URL and COMMANDSPHERE_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

  • Per-request nonce stamped on app-root as ngCspNonce.
  • Critical CSS inlining disabled in angular.json and in CommonEngine.render, because it emitted inline onload handlers.
  • [style.*] bindings replaced: static classes in UiIconComponent and UiSkeletonComponent, an SVG rect width for the analytics bar.
  • Prerendered *.html requests, matched case-insensitively, go through the render instead of express.static, which had served them raw with a one-year cache.

Validation

Independent adversarial audit, approved on the final rebased state (base ec2ddfa):

  • CSP enforced on rendered routes, all prerendered documents, upper-case variants, /runtime-config.js, and API 200/401/404/405/422 responses.
  • In a real browser, an injected inline <script> and <img onerror> are blocked and reported. Pages hydrate with zero violations.
  • Pest 88 passed · Pint PASS (132 files) · tsc/lint clean · Jest 40 passed · SSR build OK · E2E 6 passed / 1 failed · composer audit clean · npm audit 0 critical.

The one E2E failure is pre-existing and unrelated: portfolio-happy-paths.spec.ts asserts Run # while the pt-BR UI renders Execução #. It is tracked in the brain backlog.

Docs

ADR-30 in docs/DECISIONS.md, BRAIN-009, F-008 marked mitigated, and a handoff in brain/handoffs/.

🤖 Generated with Claude Code

LuanTrindade95 and others added 10 commits September 21, 2026 09:39
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>
@LuanTrindade95
LuanTrindade95 merged commit 25bb2a6 into main Sep 21, 2026
4 checks passed
@LuanTrindade95
LuanTrindade95 deleted the fix/security-content-policy branch September 23, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant