Repository navigation
security: make the hash-based CSP enforceable and collect its reports (#154) - #172
Merged
Merged
Conversation
…#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>
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.
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.
onloadhandlers that blocked enforcement (Fix ReferenceError in the AI error path (quota refund read try-scoped vars) + quiet CSP report-only noise #84) are gone.csp.violationlog events. Until now the Report-Only header had noreport-uri, so violations only ever reached each visitor's own console.CSP_MODEis set explicitly on every deploy (_CSP_MODE, defaultreport-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
dist/index.html,index.prerendered.html,prerendered/*.html) and the client code were checked.<link … media="print" onload="this.media='all'">stylesheets.javascript:URLs.srcscripts: gtag, adsbygoogle, Turnstile, the PayPal SDK.index.html: theonloadattributes are replaced bydata-async-cssplus a small inline script that swapsmediatoallwhen each sheet loads, or at once if it is already cached. That script is hashed like the others. Without this, enforcing would leavestyles.cssprint-only, i.e. an unstyled site.server/securityHeaders.js:STRICT_SCRIPT_HOSTS, replacinghttps://*.cloudflare.com(which includes cdnjs),*.google,*.google.com,*.googletagmanager.com,*.googlesyndication.comand*.doubleclick.netwith:challenges.cloudflare.comandstatic.cloudflareinsights.comwww.googletagmanager.com,www.google.com,www.googleadservices.comandgoogleads.g.doubleclick.net(gtag plus Ads conversion)pagead2.googlesyndication.com,tpc.googlesyndication.com,*.adtrafficquality.google,adservice.google.comandfundingchoicesmessages.google.com(AdSense and its CMP)adnxs.comandwww.paypalobjects.com'report-sample'andreport-uri /api/csp-report.report-tois deliberately not used: when it is present Chrome skipsreport-uri. In headless Chrome here,report-urireports were delivered immediately, while Reporting API reports sat atQueued/Pendingwith 0 attempts.computeInlineScriptSourcesnow 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 inindex.htmlwould have done exactly that.server/cspReport.js(new) andserver.js:POST /api/csp-report.application/csp-reportandapplication/reports+jsonwith a 64 KB body limit, cuts URLs to origin + path, and caps every field.csp.violation: INFO forreport, WARNING forenforce, withsuppressedSinceLastcounts. Nothing is stored./api/limiter, so reports never spend a visitor's API budget./health.cloudbuild.yaml/deploy-with-secret.sh: a_CSP_MODE: 'report-only'substitution (validated to bereport-onlyorenforcein the pre-build step) is passed asCSP_MODEon every deploy, and the same goes for the script. Any out-of-bandCSP_MODEon 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_MODEand the report events.Refs #154. Remaining: flip
_CSP_MODEtoenforceafter 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".tests/securityHeaders.test.mjs:'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/aboutand/stats/embed, and the needed hosts are still present.report-uri /api/csp-reportand'report-sample', and noreport-to.index.html, parsed with jsdom: every executable inline script's sha256 is in the hash set, and no element has anon*attribute.securityHeaders.js+index.html, 4 of these fail.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.NODE_ENV=production CSP_MODE=enforce … node server.jsand drove headless Chrome over CDP. For/,/analyzer,/donate,/stats,/stats/embedand/aboutI fired the first-interaction events (which load gtag + AdSense), waited 6 s, and read everysecuritypolicyviolation.media=all.pagead2.googlesyndication.com,www.googletagmanager.com,challenges.cloudflare.com(on/analyzer) andwww.paypalobjects.com(on/donate) all loaded, andadsbygoogleandgoogle_tag_managerinitialized./about: an<img onerror>handler, a script-inserted inline script, ajavascript: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 4csp.violationWARNING lines.report-onlymode the same probe still executes (legacy policy), and the collector logs the violations as INFO withdisposition: "report".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 reportsAfter this deploys, let report-only run for at least 7 days, so ad rotation and weekly traffic patterns are covered. Then query Cloud Logging:
Group by
jsonPayload.directive,jsonPayload.blocked,jsonPayload.sampleandjsonPayload.document.suppressedSinceLastgives the volume.Must be zero. Each of these would break the site when enforced, so fix it and redeploy before flipping:
blocked:"inline"whosesampleis 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), orif ('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.directive:"script-src-attr"on our own pages: an inline handler that is still present.directivescript-src/script-src-elemwithblockedanhttps://host that the site's own third parties use butSTRICT_SCRIPT_HOSTSlacks. Likely candidates if AdSense needs them:https://www.gstatic.com,https://securepubads.g.doubleclick.net,https://partner.googleadservices.com,https://*.googlesyndication.comsubdomains other thanpagead2/tpc, ahttps://www.google.<ccTLD>. Add the exact host and redeploy.blocked:"eval"from a Google ad or tag script would need'unsafe-eval', which is a separate decision.Expected and safe to ignore:
source/blockedstarting withchrome-extension,moz-extensionorsafari-web-extension: browser extensions.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'incloudbuild.yaml(one line) and merge; the deploy step purges Cloudflare. Afterwards, watch the same query filtered toseverity="WARNING". Rollback: set it back toreport-only, or for an immediate stopgcloud run services update bsod-analyzer --region=us-east1 --update-env-vars CSP_MODE=report-onlyfollowed by a Cloudflare purge. Edge-cached HTML carries the old header until purged.Deploy notes
mainruns CI (npm run check) and the Cloud Build trigger: image build, Cloud Run deploy withCSP_MODE=report-onlynow 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).onload. The behavior is the same; it was verified in Chrome that both reachmedia=all.report-uri, so browsers start POSTing violation reports to/api/csp-report._CSP_MODE. A trigger override must be exactlyreport-onlyorenforce./web/update-all-npm.pycron 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.server.jsonly at the import list and next to/health, away from the parallel server-side PR's areas.Ops follow-ups
bsod.windowsforum.com. Decide before flipping:/cdn-cgi/challenge-platform/…/main.jsitself 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._CSP_MODEtoenforceonce the 7-day report check above is clean. This is a one-line follow-up PR.www.googletagmanager.comstill 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)
maininto 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: incloudbuild.yamlanddeploy-with-secret.shthe--update-env-varsline now carries bothMAX_INSTANCES=…(from security: share AI quota and cost-bearing limits across instances (#134, #135) #159) andCSP_MODE=…, and both new blocks (MAX_INSTANCES,CSP_MODE) are kept. The resolved diff againstmainadds only the CSP lines. Re-tested on the merged tree:npm test338/338,tsc --noEmit0.gcloud run services update --image, then the Cloudflare purge) and does not readcloudbuild.yaml. So_CSP_MODEin that file does not reach the service on a merge, and editing it to'enforce'would change nothing in production. The service has noCSP_MODEset today, so the default (report-only) applies and this PR leaves what is enforced unchanged (the enforced header is byte-identical tomain's in every mode exceptenforce). The real flip is a Cloud Run env var, and later image-only deploys keep it:CSP_MODE=report-only(or--remove-env-vars CSP_MODE), then purge. Thecloudbuild.yamlcomment andCLAUDE.mdnow say this; no code changed.text/plain,application/jsonand multipart bodies to/api/csp-reportare 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 treatcsp.violationcounts and thedispositionfield as untrusted input when setting alerts.