Skip to content

fix(csp): enforce the Content-Security-Policy, and fix what enforcing exposed - #88

Merged
chriskehayias merged 1 commit into
mainfrom
fix/csp-enforce
Sep 13, 2026
Merged

chriskehayias merged 1 commit into
mainfrom
fix/csp-enforce

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

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 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 old 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, so style-src-attr never applied, and it fell through to style-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-attr is dropped as redundant.

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 — the control that matters.

2. upgrade-insecure-requests in report-only

Browsers 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 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) 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-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.

Final policy

default-src 'self'; script-src 'self' 'nonce-…' 'strict-dynamic';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: <MP origin>;
font-src 'self'; connect-src 'self'; object-src 'none'; frame-src 'none';
base-uri 'self'; form-action 'self' <MP origin>; frame-ancestors 'none';
upgrade-insecure-requests

Tests

978 passing / 56 files; lint and tsc --noEmit 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'.

Not CSP — found during the walk, 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.
  • One transient discovery failure at boot disables the OAuth provider for the life of the process, with no retry.

Docs updated in .claude/references/security-headers.md and .env.example.

🤖 Generated with Claude Code

… 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

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chriskehayias
chriskehayias merged commit 719918b into main Sep 13, 2026
3 checks passed
@chriskehayias
chriskehayias deleted the fix/csp-enforce branch September 13, 2026 01:02
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