Commit 7ee628c
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.
## 1 parent 1cc054c commit 7ee628c
13 files changed
Lines changed: 721 additions & 20 deletions
File tree
- docs
- pro
- react_on_rails_pro/spec/dummy
- app
- controllers
- views
- layouts
- pages
- client/app
- ror-auto-load-components
- utils
- config/initializers
- e2e-tests
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
| 215 | + | |
215 | 216 | | |
216 | 217 | | |
217 | 218 | | |
| |||
0 commit comments