Skip to content

security: make the hash-based CSP enforceable and collect its reports (#154) - #172

Merged
faratech merged 3 commits into
mainfrom
sec/csp-enforce
Oct 9, 2026
Merged

faratech merged 3 commits into
mainfrom
sec/csp-enforce

Conversation

@faratech

@faratech faratech commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR makes the hash-based CSP enforceable and gives the Report-Only stage real data. It does not turn enforcement on by itself.

  • The two inline onload handlers that blocked enforcement (Fix ReferenceError in the AI error path (quota refund read try-scoped vars) + quiet CSP report-only noise #84) are gone.
  • The strict policy's script hosts are narrowed from wildcards to exact hosts, which drops the cdnjs gadget source among others.
  • Violations of the strict policy are now collected server-side as csp.violation log events. Until now the Report-Only header had no report-uri, so violations only ever reached each visitor's own console.
  • CSP_MODE is set explicitly on every deploy (_CSP_MODE, default report-only). Enforcement is a one-line flip once the reports below come back clean.

I tested the built site locally in Chrome with CSP_MODE=enforce: zero violations across 6 routes with gtag, AdSense, Turnstile and PayPal loading, and injected script blocked and reported. What can't be checked locally is real ad serving and the Cloudflare edge's own injections, and those decide when to flip.

Changes

  • Inline execution needs, audited. Built HTML (dist/index.html, index.prerendered.html, prerendered/*.html) and the client code were checked.
    • Inline scripts: theme pre-paint, analytics/AdSense loader, service-worker registration, plus non-executed JSON-LD. All are already hashed at boot from the served bytes.
    • Inline handlers: only the two <link … media="print" onload="this.media='all'"> stylesheets.
    • No javascript: URLs.
    • Runtime script injection only adds external src scripts: gtag, adsbygoogle, Turnstile, the PayPal SDK.
  • index.html: the onload attributes are replaced by data-async-css plus a small inline script that swaps media to all when each sheet loads, or at once if it is already cached. That script is hashed like the others. Without this, enforcing would leave styles.css print-only, i.e. an unstyled site.
  • server/securityHeaders.js:
    • The strict (hash) policy now uses STRICT_SCRIPT_HOSTS, replacing https://*.cloudflare.com (which includes cdnjs), *.google, *.google.com, *.googletagmanager.com, *.googlesyndication.com and *.doubleclick.net with:
      • challenges.cloudflare.com and static.cloudflareinsights.com
      • www.googletagmanager.com, www.google.com, www.googleadservices.com and googleads.g.doubleclick.net (gtag plus Ads conversion)
      • pagead2.googlesyndication.com, tpc.googlesyndication.com, *.adtrafficquality.google, adservice.google.com and fundingchoicesmessages.google.com (AdSense and its CMP)
      • adnxs.com and www.paypalobjects.com
    • It also adds 'report-sample' and report-uri /api/csp-report.
      • report-to is deliberately not used: when it is present Chrome skips report-uri. In headless Chrome here, report-uri reports were delivered immediately, while Reporting API reports sat at Queued/Pending with 0 attempts.
    • The legacy enforcing policy is byte-for-byte unchanged.
    • computeInlineScriptSources now skips HTML comments in the same pass, so a comment that mentions a script tag can't swallow a real script and leave it unhashed. Without this, the explanatory comment first drafted in index.html would have done exactly that.
  • server/cspReport.js (new) and server.js: POST /api/csp-report.
    • It accepts application/csp-report and application/reports+json with a 64 KB body limit, cuts URLs to origin + path, and caps every field.
    • Each distinct violation is logged once per 10 minutes as csp.violation: INFO for report, WARNING for enforce, with suppressedSinceLast counts. Nothing is stored.
    • It has its own per-IP limiter (60 per 15 min) and is registered ahead of the general /api/ limiter, so reports never spend a visitor's API budget.
    • The server.js change is 15 lines next to /health.
  • cloudbuild.yaml / deploy-with-secret.sh: a _CSP_MODE: 'report-only' substitution (validated to be report-only or enforce in the pre-build step) is passed as CSP_MODE on every deploy, and the same goes for the script. Any out-of-band CSP_MODE on the service is therefore overwritten by the explicit value from now on.
  • CLAUDE.md: the stale "run hash-inline-scripts.js" note now describes the boot-time hashing, CSP_MODE and the report events.

Refs #154. Remaining: flip _CSP_MODE to enforce after the report check below, and decide about Cloudflare's injected script (Ops follow-ups). This PR alone leaves 'unsafe-inline' in the enforced header, so it does not close the issue.

Testing

  • npm test: 303 pass, 0 fail.
  • npx tsc --noEmit (npm run typecheck): exit 0.
  • npm run build: client, SSR, SRI and prerender succeed; "HYDRATION CHECK PASSED".
  • New tests in tests/securityHeaders.test.mjs:
    • Enforced script-src has no 'unsafe-inline' and none of the wildcard hosts: this is the test CSP still enforces script-src 'unsafe-inline' and wildcard script hosts; hash-based policy never left Report-Only (incomplete fix of #74) #154 asked for, on both /about and /stats/embed, and the needed hosts are still present.
    • The strict policy has report-uri /api/csp-report and 'report-sample', and no report-to.
    • Report-only stages exactly the script-src that enforce would send, while the legacy header is unchanged.
    • The hasher ignores a script tag inside a comment.
    • index.html, parsed with jsdom: every executable inline script's sha256 is in the hash set, and no element has an on* attribute.
    • Against the pre-fix securityHeaders.js + index.html, 4 of these fail.
  • New tests/cspReport.test.mjs: both wire formats normalize to the same fields with queries stripped, junk is ignored, the dedupe window and suppressed count work, and enforced violations log as WARNING. Through the compat app, both content types get 204 and a 65 KB body gets 413.
  • Real browser, enforce mode. I booted the built site with NODE_ENV=production CSP_MODE=enforce … node server.js and drove headless Chrome over CDP. For /, /analyzer, /donate, /stats, /stats/embed and /about I fired the first-interaction events (which load gtag + AdSense), waited 6 s, and read every securitypolicyviolation.
    • 0 violations on every route.
    • Both async stylesheets reached media=all.
    • pagead2.googlesyndication.com, www.googletagmanager.com, challenges.cloudflare.com (on /analyzer) and www.paypalobjects.com (on /donate) all loaded, and adsbygoogle and google_tag_manager initialized.
    • Injection probe on /about: an <img onerror> handler, a script-inserted inline script, a javascript: URL and <script src=https://cdnjs.cloudflare.com/…/angular.min.js> were all blocked (none executed). Chrome POSTed 4 reports to /api/csp-report (204) and the server logged 4 csp.violation WARNING lines.
    • In the default report-only mode the same probe still executes (legacy policy), and the collector logs the violations as INFO with disposition: "report".
  • Not covered locally: real ad creatives (AdSense serves no ads to 127.0.0.1), Turnstile rendering against the production site key, and anything the Cloudflare edge injects. That is what the report check below is for.

Before flipping to enforce: what to check in the reports

After this deploys, let report-only run for at least 7 days, so ad rotation and weekly traffic patterns are covered. Then query Cloud Logging:

resource.type="cloud_run_revision"
resource.labels.service_name="bsod-analyzer"
jsonPayload.event="csp.violation"

Group by jsonPayload.directive, jsonPayload.blocked, jsonPayload.sample and jsonPayload.document. suppressedSinceLast gives the volume.

Must be zero. Each of these would break the site when enforced, so fix it and redeploy before flipping:

  1. blocked:"inline" whose sample is one of our inline scripts. Those start with (function () { + try { var params = new URLSearchParams (theme), window.__ANALYTICS_ENABLED__ (loader), (function () { + var links = document.querySelectorAll('link[data-async-css]') (stylesheet swap), or if ('serviceWorker' in navigator). A hit means the bytes the browser received differ from the bytes hashed at boot, i.e. something rewrites HTML between Cloud Run and the browser: Cloudflare Rocket Loader, HTML minification, an edge worker.
  2. directive:"script-src-attr" on our own pages: an inline handler that is still present.
  3. directive script-src/script-src-elem with blocked an https:// host that the site's own third parties use but STRICT_SCRIPT_HOSTS lacks. Likely candidates if AdSense needs them: https://www.gstatic.com, https://securepubads.g.doubleclick.net, https://partner.googleadservices.com, https://*.googlesyndication.com subdomains other than pagead2/tpc, a https://www.google.<ccTLD>. Add the exact host and redeploy.
  4. blocked:"eval" from a Google ad or tag script would need 'unsafe-eval', which is a separate decision.

Expected and safe to ignore:

  • source/blocked starting with chrome-extension, moz-extension or safari-web-extension: browser extensions.
  • Random third-party hosts from ISP or antivirus injection.
  • Violations under connect-src/frame-src/img-src/style-src. Those directives are identical in both policies, so they're already enforced today.

Needs an owner decision (see Ops follow-ups): blocked:"inline" with a sample like (function(){function c(){var b=a.contentD… or anything containing __CF$cv$params. That is Cloudflare's per-request JavaScript-detections snippet (#84). It can never be hashed, so enforcement will block it.

Flip: change _CSP_MODE: 'report-only' to 'enforce' in cloudbuild.yaml (one line) and merge; the deploy step purges Cloudflare. Afterwards, watch the same query filtered to severity="WARNING". Rollback: set it back to report-only, or for an immediate stop gcloud run services update bsod-analyzer --region=us-east1 --update-env-vars CSP_MODE=report-only followed by a Cloudflare purge. Edge-cached HTML carries the old header until purged.

Deploy notes

  • Merging to main runs CI (npm run check) and the Cloud Build trigger: image build, Cloud Run deploy with CSP_MODE=report-only now explicit, and Cloudflare purge. The build runs the prerender and the hydration-verify gate (verify-hydration gate is run by nothing — hydration regressions ship green #128).
  • What changes for visitors on merge:
    • The async stylesheets load through the new script instead of onload. The behavior is the same; it was verified in Chrome that both reach media=all.
    • The Report-Only header now carries narrower hosts plus report-uri, so browsers start POSTing violation reports to /api/csp-report.
    • The enforced policy is unchanged.
  • The pre-build step now also validates _CSP_MODE. A trigger override must be exactly report-only or enforce.
  • Log volume: at most one line per distinct violation per instance per 10 minutes, plus per-IP rate limiting.
  • After the merge, re-sync the host checkout the hourly /web/update-all-npm.py cron pushes from: cd /bsod-analyzer && flock /tmp/cron_locks/update_all_npm.lock git fetch origin && flock /tmp/cron_locks/update_all_npm.lock git reset --keep origin/main.
  • This touches server.js only at the import list and next to /health, away from the parallel server-side PR's areas.

Ops follow-ups

  • Cloudflare JavaScript detections on bsod.windowsforum.com. Decide before flipping:
    • either turn it off for this hostname (Cloudflare dashboard → Security → Bots / a configuration rule), or
    • accept that enforcement blocks the inline bootstrap (/cdn-cgi/challenge-platform/…/main.js itself is same-origin and allowed). In that case, first confirm that no WAF or bot rule relies on the JS-detection result, or real visitors could start getting challenged.
  • Flip _CSP_MODE to enforce once the 7-day report check above is clean. This is a one-line follow-up PR.
  • Residual risk, for later: with a host allowlist, www.googletagmanager.com still lets an injected <script src=…/gtm.js?id=GTM-ATTACKER> run. Google's recommended fix is a per-response nonce with 'strict-dynamic', which conflicts with edge-cached HTML (a cached nonce isn't secret). The alternative is loading the app bundle from a hashed inline loader so 'strict-dynamic' can work with hashes alone. Both are bigger changes than this issue.

🤖 Generated with Claude Code

Review fixes (pre-merge review)

  • Merged main into this branch (merge commit, no rebase) to resolve the conflict with security: share AI quota and cost-bearing limits across instances (#134, #135) #159 that arrived when the server batch landed: in cloudbuild.yaml and deploy-with-secret.sh the --update-env-vars line now carries both MAX_INSTANCES=… (from security: share AI quota and cost-bearing limits across instances (#134, #135) #159) and CSP_MODE=…, and both new blocks (MAX_INSTANCES, CSP_MODE) are kept. The resolved diff against main adds only the CSP lines. Re-tested on the merged tree: npm test 338/338, tsc --noEmit 0.
  • Correction to "Flip" and "Rollback" above. The live Cloud Build trigger runs its own inline steps (docker build/push, then gcloud run services update --image, then the Cloudflare purge) and does not read cloudbuild.yaml. So _CSP_MODE in that file does not reach the service on a merge, and editing it to 'enforce' would change nothing in production. The service has no CSP_MODE set today, so the default (report-only) applies and this PR leaves what is enforced unchanged (the enforced header is byte-identical to main's in every mode except enforce). The real flip is a Cloud Run env var, and later image-only deploys keep it:
    gcloud run services update bsod-analyzer --region us-east1 --project project-bigfoot --update-env-vars CSP_MODE=enforce
    # then purge the Cloudflare cache for bsod.windowsforum.com (HTML is cached with the old header until then)
    
    Rollback: the same command with CSP_MODE=report-only (or --remove-env-vars CSP_MODE), then purge. The cloudbuild.yaml comment and CLAUDE.md now say this; no code changed.
  • Not changed, for the owner: text/plain, application/json and multipart bodies to /api/csp-report are accepted (204, nothing logged) under the app-wide body limit like every other POST route; only the two report content types get the 64 KB cap. Reports are unauthenticated, so treat csp.violation counts and the disposition field as untrusted input when setting alerts.

faratech and others added 3 commits October 9, 2026 15:00
…#154)

- The stylesheet links' onload="this.media='all'" handlers (which no hash
  policy can allow) are replaced by an inline swap script, hashed at boot
  like the other inline scripts. The boot-time hasher now skips HTML
  comments, so a comment mentioning a script tag can't swallow a real one.
- The hash-based policy narrows script-src to exact hosts: no
  *.cloudflare.com (cdnjs gadgets), *.google(.com), *.googletagmanager.com,
  *.googlesyndication.com or *.doubleclick.net. It adds 'report-sample'
  and report-uri /api/csp-report. The legacy enforcing policy is unchanged.
- New server/cspReport.js: POST /api/csp-report accepts csp-report and
  reports+json, drops queries, caps fields, and logs each distinct violation
  once per 10 min as a csp.violation event (own per-IP limiter, registered
  ahead of the general API limiter).
- CSP_MODE is set explicitly on every deploy: _CSP_MODE substitution
  (default 'report-only', validated) in cloudbuild.yaml, CSP_MODE in
  deploy-with-secret.sh. Flipping to 'enforce' is a one-line change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the one-line conflict with #159 in cloudbuild.yaml and
deploy-with-secret.sh: keeps MAX_INSTANCES and the quota-store secret
binding from main and adds CSP_MODE to the same --update-env-vars line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e the real flip command

The auto-deploy trigger runs inline steps (docker build/push, then
'gcloud run services update --image', then the Cloudflare purge) and never reads
cloudbuild.yaml, so changing _CSP_MODE there is a no-op for the live service and
the explicit CSP_MODE does not reach it. Say so in the cloudbuild.yaml comment
and in CLAUDE.md, and document the real flip/rollback (Cloud Run env var update
plus a Cloudflare purge). No code or behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@faratech
faratech merged commit 34e5fc7 into main Oct 9, 2026
3 checks passed
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