Skip to content

Commit 7ee628c

Browse files
justin808claude
andauthored
Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy (#3934)
Fixes #3879 ## Summary The CSP-nonce plumbing for streamed RSC shipped in 16.4.0 (PRs 2398/2418) but had zero automated coverage under a real strict policy and no documentation. This PR proves it holds end-to-end and documents the recipe: - **Strict CSP enforced globally in the Pro dummy app** (`script-src 'self'` + per-request nonce, NO `unsafe-inline`; development-only carve-outs for webpack-dev-server/HMR/eval) via the new `react_on_rails_pro/spec/dummy/config/initializers/content_security_policy.rb`. - **New Playwright E2E** (`react_on_rails_pro/spec/dummy/e2e-tests/strict_csp.spec.ts`): loads streamed RSC pages under the enforced policy with a `securitypolicyviolation` listener installed before any page script, and asserts zero violations + interactive hydration (input responds, RSC payload arrays populated, console replay executed). A canary test injects a nonce-less inline script and expects it blocked, proving the zero-violation assertion is not vacuous. Runs automatically in the existing `dummy-app-node-renderer-e2e-tests` CI job — no workflow changes. - **New docs page** `docs/pro/strict-csp.md`: Rails initializer recipe, nonce flow (Rails `content_security_policy_nonce` → `railsContext.cspNonce` → node renderer → injected `<script nonce>`), what is/isn't nonce-covered and why `type="application/json"` data tags need no nonce, caching caveats, troubleshooting. - **Dummy-app fixes to stay green under the policy** (each demonstrates a documented pattern): nonce the prism CDN tags (+ `preload_links_header: false` — preload headers can't carry nonces), replace inline `onchange` handlers with a nonced script, nonce the committed-stream error script, and thread `railsContext.cspNonce` into the Apollo `__APOLLO_STATE__` tags and React Router's `StaticRouterProvider` hydration script. No gem/package code changes were needed: the audit found every executable inline script in the streamed-RSC + hydration path already nonce-covered. ## Nonce audit | # | Emission site | File:lines | Executable? | Nonce? | Verified how | |---|---|---|---|---|---| | 1 | RSC payload array init script | `packages/react-on-rails-pro/src/injectRSCPayload.ts:56-61,346` | Yes | Yes (`sanitizeNonce(cspNonce)`) | E2E zero-violations + curl body scan (all executable inline tags nonced) | | 2 | RSC Flight payload chunk scripts | `injectRSCPayload.ts:63-65,367` | Yes | Yes | E2E + `REACT_ON_RAILS_RSC_PAYLOADS` populated under enforced policy | | 3 | RSC diagnostic script | `injectRSCPayload.ts:78-91,361` | Yes | Yes | curl body scan (diagnostic emitted in local run, nonced) | | 4 | Streamed console-replay scripts | `injectRSCPayload.ts:371-374` | Yes | Yes | E2E asserts replayed `[SERVER]` log appears in browser console | | 5 | Nonce attach helper | `injectRSCPayload.ts:48-54`; accept at `:123-125` | — | — | Code review: every `createScriptTag` call threads `sanitizedNonce` | | 6 | React hydration bootstrap + `$RC`/`$RX` runtime scripts | `packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts:118` (`nonce:` option), `:95` (cspNonce → injectRSCPayload) | Yes | Yes | curl body scan shows `$RX` script nonced; E2E hydration completes | | 7 | Immediate-hydration component script | `react_on_rails/lib/react_on_rails/pro_helper.rb:27-31` | Yes | Yes (`csp_nonce`) | curl body scan + E2E | | 8 | Immediate-hydration store script | `pro_helper.rb:52-60` | Yes | Yes | Code review (same pattern as #7) | | 9 | Console replay (non-streamed + first chunk) | `react_on_rails/lib/react_on_rails/helper.rb:554-565` | Yes | Yes (`id="consoleReplayLog"` + nonce) | curl body scan | | 10 | Component props tag | `pro_helper.rb:10-21` | **No** (`type="application/json"` data block — browser never executes; `script-src` doesn't apply) | Intentionally none | curl scan: 2 inert JSON tags, 0 nonced — by design | | 11 | Rails-context tag | `helper.rb:584-587` | **No** (same) | Intentionally none | same | | 12 | Redux store data tag | `pro_helper.rb:43-46` | **No** (same) | Intentionally none | code review | | 13 | `sanitizeNonce` false-drop check | `packages/react-on-rails/src/sanitizeNonce.ts` | — | Never drops legit Rails nonces | Live nonces with `+`, `/`, leading `+`, and `==` padding (e.g. `+k12QYROWcdDhxenkVkpHQ==`) passed through and hydrated in E2E; regex permits full base64/base64url + `={0,2}` | | 14 | h2c boundary (Rails → node renderer) | rendering request body | — | — | Checked in passing: nonce travels as JSON inside the rendering-request body; the E2E proves header nonce == every injected script nonce end-to-end through the renderer | **Gaps found and fixed (dummy-app level, not gem):** committed-stream error script (`application_controller.rb`), Apollo `__APOLLO_STATE__` tags, React Router `StaticRouterProvider` hydration script — all app-authored scripts outside the gem's helpers; fixed by threading the nonce, which doubles as the documented pattern for user apps. ## ⚠️ Cached-nonce finding (maintainer decision needed — deliberately NOT fixed here) - **`cached_stream_react_component` (and the other fragment-caching helpers) bake the populating request's nonce into cached fragments.** `fetch_stream_react_component` (`react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb:327-345`) caches the full streamed chunk array — including the nonced immediate-hydration, console-replay, and RSC `<script nonce=…>` tags — under `react_component_cache_key`, which does **not** include the nonce; `handle_stream_cache_hit` replays those chunks verbatim. Verified empirically in the dummy with a memory store: a cache hit (0.04s) served `nonce="Q16Z/…"` inside the body while the response header carried a fresh `nonce-9QNhpi2/…`. Under strict CSP the browser blocks every executable inline script in the cached fragment (immediate hydration, RSC payload, console replay) — the zero-violation guarantee breaks and streamed-RSC hydration data is lost. Options include nonce-placeholder substitution at serve time, excluding nonced scripts from the cached body, or documenting the incompatibility (the docs page currently does the latter). - **`prerender_caching` never serves a stale nonce but is silently defeated**: its cache key digests the rendering request js code, which embeds `railsContext.cspNonce`, so per-request nonces make every key unique — 0% cross-request hit rate plus per-request write churn. Documented as a caveat in the docs page. - Side observation (UNKNOWN cause): cache hits alternated between two entries for an identical page during the evidence experiment — possible nondeterminism in the view-level stream cache key worth a maintainer glance. - Not a CI risk today: the dummy test env uses `:null_store`, and `:caching`-tagged specs use a fresh per-example MemoryStore with a single visit. ## Test plan - New E2E locally: 12/12 passed (4 tests × chromium/firefox/webkit) against Rails (test env) + node renderer; final-head chromium re-run 4/4. - Full Pro dummy E2E (chromium): 33 passed; 3 failures reproduce identically **without** the CSP initializer (pre-existing local-env issues, streaming-after-navigation). - Pro dummy RSpec: helpers+requests 99/99; system 65 examples with 1 pre-existing failure (also fails without CSP, passes after bundle rebuild); root specs 107/107. - Pro rubocop (`--ignore-parent-exclusion`), ESLint, Prettier, `script/check-docs-sidebar`, `script/check-pro-license-headers`: all green. - `script/ci-changes-detector origin/main`: Pro dummy category → Pro lint + Pro dummy integration tests (which include the e2e job that runs the new spec). - Codex review (`codex review --base origin/main`): three passes; two findings accepted and fixed (committed-stream error script nonce; Apollo/Router demo script nonces); the cached-fragment finding deliberately triaged as report-only per batch instructions. ## Codex Decision Log - **Non-blocking:** CSP scope in the dummy app - **Decision:** strict policy globally ON (all envs; dev gets webpack-dev-server/HMR/eval carve-outs); per-request `SecureRandom.base64(16)` nonce; `nonce_directives = %w[script-src]`; `style-src` keeps `unsafe-inline`. - **Why:** strongest guarantee; full dummy suites stay green (baseline runs with the initializer removed prove remaining failures are pre-existing); style-src belongs to #3862. - **Review later:** scope to test env if dev-mode dummy use hits CSP friction. - **Non-blocking:** E2E tolerates exactly one violation shape — `blockedURI:"eval"` from the `react-on-rails-rsc` development Flight client (`createFakeFunction` calls `(0, eval)` for server stack frames; try/catch-guarded; production build has 0 eval call sites, verified in dist). - **Decision:** filter that one dev-only signature; everything else fails the test. - **Why:** dummy test bundles are dev-mode; the eval is React's, not ours, and degrades gracefully. - **Review later:** drop the filter if test bundles switch to production mode. - **Non-blocking:** dummy demo pages fixed to be CSP-clean (prism CDN nonce + no preload header, `addEventListener` instead of inline `onchange`, nonced Apollo/Router/committed-stream scripts). - **Decision:** fix in this PR as flagged trivially-safe, in-scope app-level changes. - **Why:** the dummy must stay functional under the policy this PR enables; each fix demonstrates the documented pattern for user apps. - **Review later:** None. - **Non-blocking:** cached-nonce behavior NOT changed; no extra Ruby request-spec for the header; no CHANGELOG entry (tests/docs/dummy only per AGENTS.md). Labels: none — dummy-app test/docs change with no gem/package/runtime code touched; path-based CI selects the Pro dummy integration suite that runs the new E2E. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are documentation, test coverage, and Pro dummy-app CSP alignment only; production gem/runtime behavior is unchanged. > > **Overview** > Adds **`docs/pro/strict-csp.md`** (linked in the Pro sidebar and **`llms-full-pro.txt`**) describing how to run streamed SSR/RSC under **`script-src` without `'unsafe-inline'`**, including Rails nonce setup, nonce flow through **`railsContext.cspNonce`**, what tags need nonces vs inert JSON blocks, **fragment/prerender caching caveats**, and troubleshooting. > > **Enforces that policy in the Pro dummy app** via a new **`content_security_policy.rb`** initializer (strict in test/production; dev carve-outs for webpack-dev-server/HMR/eval) and updates dummy views/controllers so the app stays functional: nonced Prism CDN scripts with **`preload_links_header: false`**, **`javascript_tag nonce: true`** instead of inline **`onchange`**, nonced late-stream error redirect script with safer escaping, and **`cspNonce`** on Apollo inline state tags and **`StaticRouterProvider`**. > > **Adds `serializeForInlineScript`** plus unit tests for safer JSON-in-script serialization; **Playwright E2E (`strict_csp.spec.ts`)** asserts the strict CSP header, blocks a nonce-less canary script, and verifies streamed RSC pages hydrate with no unexpected **`securitypolicyviolation`** events (tolerating dev-only Flight **`eval`** noise). > > No gem/package runtime changes in this diff—the work documents and continuously verifies existing nonce plumbing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3805069. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a Pro guide for strict Content Security Policy in streamed SSR and React Server Components, including nonce propagation, script vs inert data handling, caching caveats, and troubleshooting. * **New Features** * Updated streamed inline scripts and hydration payloads to carry per-request CSP nonces for compatibility with CSP environments that disallow `'unsafe-inline'`. * Updated dummy app behaviors to avoid inline event handlers and nonce-inject third-party script tags. * **Tests** * Added/expanded CSP-focused Playwright E2E coverage to verify header presence and enforcement. * Added unit tests for safe inline-script serialization. <!-- end of auto-generated comment: release notes by coderabbit.ai --> ## Merge Readiness Criteria Current evaluation as of 2026-06-15 for head `38050696a14188ee27a9c9a53e00cbad40b59a9b`. - **Release mode:** `accelerated-rc`, from canonical release gate #3823 (`Agent Release Mode` block verified live on 2026-06-15). - **Current head SHA:** `38050696a14188ee27a9c9a53e00cbad40b59a9b`. - **Branch freshness:** current `origin/main` was merged into the PR branch; generated `llms-full.txt`/`llms-full-pro.txt` were verified after the merge update. - **CI/check status:** Complete for current head: 25 passing checks and 9 expected/path-selected skips; no pending or failing checks. Key passes include `check-llms-full`, `dummy-app-node-renderer-e2e-tests`, `rspec-dummy-app-node-renderer`, `dummy-app-rspack-rsc-runtime-gate`, `pro-lint`, `build`, CodeQL, `claude-review`, Cursor Bugbot, and CodeRabbit. `mergeStateStatus` is `CLEAN`. - **Review-thread status:** Paginated GraphQL review-thread sweep reports 42 total threads and `unresolved=0` immediately before merge. - **Review feedback triage:** Prior Claude/Codex/Cursor/CodeRabbit feedback was fixed or explicitly triaged in the existing PR history. The latest update only merged `main` and regenerated/verified generated LLMS output; no #3963-reserved hooks v6 / React Compiler / RSC compiler-boundary docs were touched. - **Validation run:** `node script/generate-llms-full.mjs --check` -> passed (`llms-full.txt 1621 KiB`, `llms-full-pro.txt 490 KiB`); `bash script/generate-llms-full-test.bash` -> passed in prior readiness run; `git diff --check` -> passed; `script/check-docs-sidebar` -> passed; `pnpm start format.listDifferent` -> passed; `bin/check-links` prior readiness run -> passed; OSS RuboCop prior readiness run -> passed; pre-push hook prior readiness run -> passed. - **Label/CI decision:** Labels: none. Path-selected CI includes the Pro dummy/RSC checks relevant to this docs/dummy CSP verification PR; no benchmark/full-CI expansion recommended. - **Known residual risk:** The documented cached-fragment nonce caveat remains intentionally not fixed in this PR. No current-head CI/review blocker is known. - **Merge recommendation:** Merge now. Maintainer marked #3934 approved and explicitly authorized merge if confidence is at least 8/10; current score is 8/10. ## Agent Merge Confidence Mode: accelerated-rc Current head SHA: `38050696a14188ee27a9c9a53e00cbad40b59a9b` Score: 8/10 Auto-merge recommendation: yes, under the explicit maintainer approval/authorization in the batch thread for #3934. Affected areas: Pro dummy app strict CSP, streamed RSC verification, Pro CSP docs, generated LLMS references. CI detector: prior readiness run selected Pro dummy integration/E2E and docs/LLMS checks; current-head GitHub checks completed those relevant gates. Validation run: - `node script/generate-llms-full.mjs --check` -> pass. - `git diff --check` -> pass. - `pnpm start format.listDifferent` -> pass. - Current-head GitHub checks -> 25 pass / 9 expected skips / 0 pending / 0 failed. Review/check gate: - GitHub checks: complete for `38050696a14188ee27a9c9a53e00cbad40b59a9b`; skips are path-selected helper skips. - Review threads: GraphQL unresolved count is 0. - Current-head reviewer verdicts: `claude-review`, Cursor Bugbot, CodeRabbit completed with no unresolved blocker threads. Known residual risk: cached-fragment nonce caveat is documented but not fixed in this PR. Finalized by: maintainer approval and explicit merge authorization in the Codex batch thread; live GitHub maintainer comment from `justin808` says “Approved by maintainer.” Confidence note: - Validated: local merge-update LLMS/format/whitespace checks, prior local docs/link/RuboCop/pre-push evidence, and current-head GitHub checks. - Evidence: `gh pr checks 3934` -> 25 pass / 9 skip / 0 pending / 0 failed; GraphQL review threads -> 42 total / 0 unresolved; `mergeStateStatus` -> `CLEAN`. - UNKNOWN: none that affects merge qualification. - Residual risk: cached-fragment nonce behavior is documented but not fixed. - Decision points: 1 (maintainer approval/merge authorization). --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1cc054c commit 7ee628c

13 files changed

Lines changed: 721 additions & 20 deletions

File tree

docs/pro/strict-csp.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Strict Content Security Policy (CSP)
2+
3+
React on Rails Pro's streaming SSR and React Server Components inject inline `<script>` tags into the HTML stream — RSC Flight payload chunks, per-component initialization scripts, console-replay scripts, immediate-hydration scripts, and React's own Suspense-boundary completion scripts. Under a strict Content Security Policy with **no `'unsafe-inline'`**, the browser executes an inline script only when it carries a `nonce` matching the response's CSP header.
4+
5+
React on Rails Pro threads Rails' own per-request CSP nonce (`content_security_policy_nonce`) through the entire streaming pipeline, so a strict policy like:
6+
7+
```text
8+
script-src 'self' 'nonce-<per-request-value>'
9+
```
10+
11+
works end to end: the page streams, every injected inline script executes, and hydration completes with zero CSP violations. This is verified continuously by an E2E test (`react_on_rails_pro/spec/dummy/e2e-tests/strict_csp.spec.ts`) that loads a streamed RSC page under the strict policy enforced in the Pro dummy app and asserts zero `securitypolicyviolation` events plus successful interactive hydration.
12+
13+
Because the nonce comes from Rails' native CSP support, there is no framework-specific nonce mechanism to configure — it integrates with your app's existing `content_security_policy` initializer.
14+
15+
## The Rails Recipe
16+
17+
Configure a strict policy in `config/initializers/content_security_policy.rb`:
18+
19+
```ruby
20+
Rails.application.configure do
21+
config.content_security_policy do |policy|
22+
policy.default_src :self, :https
23+
policy.font_src :self, :https, :data
24+
policy.img_src :self, :https, :data
25+
policy.object_src :none
26+
policy.script_src :self
27+
# Style nonces are not covered by React on Rails (see "Scope" below).
28+
policy.style_src :self, :https, :unsafe_inline
29+
policy.connect_src :self, :https
30+
end
31+
32+
# Per-request nonce for normal full-page navigation. Use a session-stable
33+
# generator instead when Turbo/Turbolinks keeps the original document policy.
34+
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
35+
36+
# Append the nonce to script-src only.
37+
config.content_security_policy_nonce_directives = %w[script-src]
38+
end
39+
```
40+
41+
Notes:
42+
43+
- With a nonce generator configured and `script-src` in `content_security_policy_nonce_directives`, Rails appends `'nonce-…'` to the `script-src` directive of every response automatically.
44+
- **Per-request vs. per-session nonce**: `SecureRandom.base64(16)` generates a fresh nonce per request. Use it for normal full-page navigations. If your app uses Turbo/Turbolinks visits that can load streamed SSR/RSC pages, prefer a session-based generator derived from the session ID without exposing the raw session ID, for example `->(request) { Digest::SHA256.hexdigest("csp-nonce-#{request.session.id}")[0, 32] }`. Add `require "digest"` when using this session-stable example. Executable inline scripts in Turbo-fetched pages carry the new response's nonce while the active document still enforces the original page's policy. React on Rails' inert JSON data tags and same-origin client bundle work with either nonce lifetime; the session-based preference applies to executable inline scripts in Turbo/Turbolinks-fetched streamed responses.
45+
- Browsers ignore `'unsafe-inline'` for a directive once that directive contains a nonce, so adding it as a fallback for legacy browsers is harmless but does not weaken the policy in modern browsers.
46+
- In production, configure CSP violation reporting (`report-uri` or `report-to`, often in report-only mode first) so nonce regressions show up before users report hydration failures.
47+
- A development environment usually needs extra allowances for `webpack-dev-server` (the bundle origin, the HMR websocket, and `'unsafe-eval'` for eval-based source maps). See the Pro dummy app's initializer (`react_on_rails_pro/spec/dummy/config/initializers/content_security_policy.rb`) for a working example that stays strict in test/production.
48+
49+
## How the Nonce Flows
50+
51+
1. **Rails generates the nonce** per request via `content_security_policy_nonce_generator` and adds `'nonce-…'` to the `script-src` header.
52+
2. **React on Rails reads it** with `content_security_policy_nonce(:script)` (helper `csp_nonce`) and adds it to the rails context as `railsContext.cspNonce`.
53+
3. **The rails context travels to the renderer** inside the serialized rendering request (the node renderer receives it as part of the request body, so nothing is lost across the Rails → renderer boundary).
54+
4. **The streaming pipeline applies it everywhere**:
55+
- `streamServerRenderedReactComponent` passes `nonce` to React's `renderToPipeableStream`, covering React's hydration bootstrap content and the inline Suspense-boundary completion scripts React injects while streaming.
56+
- `injectRSCPayload` adds `nonce="…"` to every script tag it generates: the RSC payload array initialization scripts, the Flight payload chunk scripts, rendering-diagnostic scripts, and streamed console-replay scripts.
57+
- The Ruby helpers add the nonce to the immediate-hydration scripts (`ReactOnRails.reactOnRailsComponentLoaded(...)` / `reactOnRailsStoreLoaded(...)`) and to the console-replay script tag.
58+
59+
The nonce value is sanitized before being emitted into HTML attributes (`sanitizeNonce`): base64/base64url characters including `+`, `/`, `_`, `-` and trailing `=` padding pass through unchanged (Rails-generated nonces are never altered), while anything that could break out of the attribute is stripped and a malformed value causes the nonce attribute to be omitted entirely rather than emitting an unsafe attribute.
60+
61+
## What Is (and Isn't) Nonce-Covered
62+
63+
| Emitted tag | Executable? | Nonce |
64+
| -------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------ |
65+
| RSC payload init / chunk / diagnostic scripts (streamed) | Yes | Yes |
66+
| Console-replay scripts (streamed and non-streamed) | Yes | Yes |
67+
| Immediate-hydration scripts (`reactOnRailsComponentLoaded` / `reactOnRailsStoreLoaded`) | Yes | Yes |
68+
| React hydration bootstrap + Suspense completion scripts | Yes | Yes (via `renderToPipeableStream` `nonce`) |
69+
| Component props tag (`<script type="application/json" class="js-react-on-rails-component">`) | No | Not needed |
70+
| Rails context tag (`<script type="application/json" id="js-react-on-rails-context">`) | No | Not needed |
71+
| Redux store props tag (`<script type="application/json" data-js-react-on-rails-store>`) | No | Not needed |
72+
73+
**Why the JSON data tags need no nonce**: `<script>` elements with a `type` attribute that is not a JavaScript MIME type are _data blocks_ — the browser never executes them, so CSP `script-src` does not apply. They exist purely as inert payloads that the (nonce-exempt, same-origin) client bundle reads during hydration. Leaving them un-nonced is intentional: it keeps cached/streamed markup free of per-request values wherever execution is not involved.
74+
75+
## Scope: `script-src` Only
76+
77+
This guarantee covers `script-src`. Strict `style-src` policies (nonced styles) are **not** covered: React 19's hoisted style precedence links and inline `<style>` usage need separate treatment, tracked in [issue #3862](https://github.com/shakacode/react_on_rails/issues/3862). Keep `'unsafe-inline'` (or hashes) in `style-src` for now if your pages use inline styles.
78+
79+
## Caching Caveats
80+
81+
Nonces are per-request values; caching renders per-request markup. Two distinct mechanisms interact differently with nonces:
82+
83+
### Fragment caching helpers bake the nonce into the cached fragment
84+
85+
`cached_react_component`, `cached_react_component_hash`, `cached_stream_react_component`, and `cached_async_react_component` cache the **final rendered HTML** (for streaming: the full chunk array) under a cache key built from your `cache_key` option plus bundle digests. The cached markup includes the executable inline scripts **with the nonce of the request that populated the cache**, and the cache key does **not** include the nonce.
86+
87+
A cache hit therefore serves a stale nonce to a different request, whose CSP header carries a different nonce — the browser blocks those inline scripts and immediate hydration/console replay silently degrade (components still hydrate via the client bundle's normal page-load path, but the strict-CSP guarantee of "zero violations" no longer holds).
88+
89+
**Recommendation**: do not combine the fragment-caching helpers with a nonce-enforcing `script-src` until this is addressed. If you need both, exclude fragment-cached components from strict enforcement (e.g., `content_security_policy_report_only` while migrating) and watch your CSP violation reports.
90+
91+
### Prerender caching never serves a stale nonce — but stops hitting
92+
93+
`config.prerender_caching` keys the cache on a digest of the full rendering request, which embeds the serialized rails context — including `cspNonce`. With a per-request nonce generator every request produces a different digest, so:
94+
95+
- **No stale nonce is ever served** from the prerender cache (the key changes whenever the nonce changes), but
96+
- **The cache effectively never hits across requests** — a silent performance regression. Each request also writes a new entry, so cache storage churns.
97+
98+
**Recommendation**: with nonce-based CSP enabled, disable `prerender_caching` for streamed/SSR pages or accept that it is inert for them.
99+
100+
## Troubleshooting
101+
102+
**Components render but never hydrate; console shows "Refused to execute inline script".**
103+
The nonce is not reaching the page. Check that both `content_security_policy_nonce_generator` _and_ `content_security_policy_nonce_directives` (including `script-src`) are configured — Rails only appends `'nonce-…'` to directives listed there. Confirm the response header contains `'nonce-…'` and that the inline script tags carry the same value.
104+
105+
**Third-party `<script src>` tags are blocked.**
106+
Either allowlist the host in `script-src` or add the nonce to the tag: `javascript_include_tag "https://cdn.example.com/lib.js", nonce: true`. Watch out for Rails' automatic `Link: rel=preload` headers: a preload header cannot carry a nonce, so the preload itself violates `script-src-elem` even when the tag is nonced. Pass `preload_links_header: false` to `javascript_include_tag` for cross-origin scripts you authorize via nonce (same-origin preloads are covered by `'self'`).
107+
108+
**Inline event handlers (`onclick="…"`, `onchange="…"`) stop working.**
109+
CSP nonces don't apply to inline event handlers. Move the logic into a nonced script (or external file) using `addEventListener`. Example: `javascript_tag nonce: true do ... end`.
110+
111+
**`blockedURI: "eval"` violations from the RSC client in development.**
112+
React's _development_ Flight client (`react-on-rails-rsc` / `react-server-dom-webpack` development build) calls `eval` to reconstruct server-component stack frames for console replay and owner stacks. The call is wrapped in a try/catch, so under a no-`'unsafe-eval'` policy it degrades gracefully (less precise stack frames; nothing breaks). The production Flight client build contains no `eval`. If the noise bothers you in development, add `'unsafe-eval'` to `script-src` **in development only** — never in production.
113+
114+
**Streaming works but a fragment-cached component misbehaves under CSP.**
115+
See [Caching Caveats](#caching-caveats) above — cached fragments carry the nonce of the request that created them.
116+
117+
**Nonce appears to be dropped/missing from injected scripts.**
118+
`sanitizeNonce` omits the nonce attribute if the value doesn't look like base64/base64url (this prevents attribute-injection attacks). Rails' built-in generators (`SecureRandom.base64`, session id) always pass. If you use a custom generator, keep its output within `[A-Za-z0-9+/_-]` plus optional trailing `=` padding.

docs/sidebars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ const sidebars: SidebarsConfig = {
212212
'pro/deployment/review-app-security',
213213
'pro/upgrading-to-pro',
214214
'pro/streaming-ssr',
215+
'pro/strict-csp',
215216
'pro/async-props-database-queries',
216217
'pro/node-renderer',
217218
'pro/rolling-deploy-adapters',

0 commit comments

Comments
 (0)