fix(csp): enforce the Content-Security-Policy, and fix what enforcing exposed - #88
Merged
Merged
Conversation
… exposed Completes F9. PR #86 landed the policy in report-only so it could be trialled in a browser before it was allowed to block anything. This is that trial, the two defects it found, and the switch to enforcing. The walk: a real browser against a PRODUCTION build (dev's `'unsafe-eval'` and `'unsafe-inline'` relaxations would have hidden violations), covering sign-in, home, contact search rendering a real MP photo, contact detail, the contact-log dialog, the select inside it, the user menu, and sign-out to MP's endsession. --- style-src: the reason report-only is not enough --- The report-only pass was completely clean. Enforcing the SAME policy immediately blocked an inline style and killed the contact-log dialog with React error #441: Applying inline style violates the following Content Security Policy directive 'style-src 'self' 'nonce-...''. The action has been blocked. Radix's dialog pulls in react-remove-scroll, which locks body scroll by INJECTING A <style> ELEMENT at runtime. The previous policy paired `style-src 'self' 'nonce-...'` with `style-src-attr 'unsafe-inline'` on the theory that a nonce could cover stylesheets while the attr directive covered Radix's inline style attributes. That theory was wrong: an injected <style> is an element, not an attribute, so `style-src-attr` never applied, and it fell through to `style-src` where a nonce cannot help — the element is created by script long after the server picked the nonce. A hash is not a workable alternative: the blocked content embeds the computed scrollbar width, so it varies by platform and zoom. Two different hashes appeared in a single page view. So `style-src 'self' 'unsafe-inline'`, and the nonce must stay OUT of that directive — CSP3 browsers ignore `'unsafe-inline'` whenever a nonce is present alongside it, which is precisely the trap that produced the broken policy. `style-src-attr` is dropped as redundant; `style-src` covers attributes and elements alike. The cost is real but bounded: inline STYLE injection permits limited selector-based exfiltration, not script execution. `script-src` keeps its nonce and `strict-dynamic`, which is the control that matters. --- upgrade-insecure-requests in report-only --- Browsers refuse to honor the directive in a report-only policy and log an error saying so on every page. It was the only CSP message in the console during the walk, burying the reports report-only exists to surface. Now omitted whenever the policy is report-only. --- enforcing by default --- `cspHeaderName()` now enforces unless `CSP_ENFORCE=false` exactly. The escape hatch is inverted from the old opt-in default on purpose: a typo now fails loud (a too-strict header) instead of silent (no policy at all), and report-only becomes the unusual state you switch on to diagnose a violation rather than the state a deploy drifts into by forgetting a variable. Verified with no env var set: `content-security-policy` is served, the enforced walk is clean, and every violation still in the browser buffer quotes the old nonce-bearing `style-src` that no longer exists. Not CSP, found during the walk and worth separate tickets: MP connectivity from this machine is intermittent (a ConnectTimeoutError to mpi.ministryplatform.com:443 during a role lookup surfaced as a 500), and one transient discovery failure at boot disables the OAuth provider for the life of the process with no retry. Tests: 978 passing in 56 files; lint and tsc clean. New coverage for the report-only upgrade-insecure-requests rule, for report-only being otherwise byte-identical to the enforced policy, for the inverted CSP_ENFORCE default, and a guard asserting a nonce never appears in style-src beside 'unsafe-inline'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Completes F9. #86 landed the policy in report-only so it could be trialled in a browser before it was allowed to block anything. This is that trial, the two defects it found, and the switch to enforcing.
The walk: a real browser against a production build (dev's
'unsafe-eval'/'unsafe-inline'relaxations would have hidden violations) — sign-in, home, contact search rendering a real MP photo, contact detail, the contact-log dialog, the select inside it, the user menu, and sign-out to MP's endsession.1.
style-src— why report-only is not enoughThe report-only pass was completely clean. Enforcing the same policy immediately blocked an inline style and killed the contact-log dialog with React error #441:
Radix's dialog pulls in
react-remove-scroll, which locks body scroll by injecting a<style>element at runtime. The old policy pairedstyle-src 'self' 'nonce-…'withstyle-src-attr 'unsafe-inline', on the theory that a nonce could cover stylesheets while the attr directive covered Radix's inline style attributes. That theory was wrong: an injected<style>is an element, sostyle-src-attrnever applied, and it fell through tostyle-src— where a nonce cannot help, because the element is created by script long after the server picked the nonce.A hash isn't viable either: the blocked content embeds the computed scrollbar width, so it varies by platform and zoom. Two different hashes appeared in a single page view.
Fix:
style-src 'self' 'unsafe-inline', with the nonce deliberately out of that directive — CSP3 browsers ignore'unsafe-inline'whenever a nonce sits alongside it, which is exactly the trap that produced the broken policy.style-src-attris dropped as redundant.The cost is real but bounded: inline style injection permits limited selector-based exfiltration, not script execution.
script-srckeeps its nonce andstrict-dynamic— the control that matters.2.
upgrade-insecure-requestsin report-onlyBrowsers refuse to honor it in a report-only policy and log an error saying so on every page. It was the only CSP message in the console during the walk, burying the reports report-only exists to surface. Now omitted whenever the policy is report-only.
3. Enforcing by default
cspHeaderName()enforces unlessCSP_ENFORCE=falseexactly. The escape hatch is inverted from the old opt-in default on purpose: a typo now fails loud (a too-strict header) rather than silent (no policy at all), and report-only becomes the unusual state you switch on to diagnose a violation — not the state a deploy drifts into by forgetting a variable.Verified with no env var set:
content-security-policyis served, the enforced walk is clean, and every violation still in the browser buffer quotes the old nonce-bearingstyle-srcthat no longer exists.Final policy
Tests
978 passing / 56 files; lint and
tsc --noEmitclean. New coverage for the report-onlyupgrade-insecure-requestsrule, for report-only being otherwise byte-identical to the enforced policy, for the invertedCSP_ENFORCEdefault, and a guard asserting a nonce never appears instyle-srcbeside'unsafe-inline'.Not CSP — found during the walk, worth separate tickets
ConnectTimeoutErrortompi.ministryplatform.com:443during a role lookup surfaced as a 500.Docs updated in
.claude/references/security-headers.mdand.env.example.🤖 Generated with Claude Code