Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy#3934
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds strict, nonce-based CSP support for React on Rails Pro streaming and RSC: Rails initializer for per-request nonce generation, nonce propagation into server-rendered components and inline scripts, view/template updates to avoid inline handlers, inline script serialization helper, Playwright E2E tests verifying zero CSP violations, and documentation. ChangesStrict CSP for Streaming & React Server Components
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a65c7571e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code Review — PR #3934: Strict CSP nonce verification for streamed RSCOverviewThis PR delivers exactly what it says on the tin: proof + documentation that every inline script injected by the streaming SSR / RSC pipeline carries the per-request Rails nonce, under a genuinely strict policy with no What's good
Issues1 — Minor reliability: canary test checks violations before streaming settles 2 — Minor security/hygiene: manual nonce interpolation in the controller 3 — Nit: let violations: CspViolation[] = [];
await expect.poll(async () => {
violations = await getUnexpectedCspViolations(page);
return violations.length;
}).toBe(1);
expect(violations[0].blockedURI).toBe('inline');Not a blocker — the window is tiny and the test is correct in spirit. SummaryApprove with minor comments. The implementation is solid, the test design is thoughtful, and the documentation is among the best in this repo for a security-sensitive feature. The two inline issues are non-blocking; the canary timing concern warrants a look if streaming latency in CI proves variable. |
Greptile SummaryThis PR verifies and documents CSP-nonce propagation for streamed RSC under a strict
Confidence Score: 4/5Safe to merge — all changes are confined to the dummy app, docs, and a new E2E test; no gem or package runtime code is touched. The PR is well-structured and thoroughly tested. The only non-trivial concern is in application_controller.rb where the CSP nonce is manually interpolated into an HTML attribute without HTML escaping — safe today with the default SecureRandom generator but brittle if the generator is ever swapped for a custom one. Everything else (initializer, E2E suite, Apollo/Router nonce fixes, docs) looks correct and intentional. react_on_rails_pro/spec/dummy/app/controllers/application_controller.rb — nonce interpolation without HTML escaping is the one spot worth a second look before merging. Important Files Changed
Sequence DiagramsequenceDiagram
participant Browser
participant Rails
participant NodeRenderer
Rails->>Rails: "Generate nonce via SecureRandom.base64(16)"
Rails->>Browser: "Content-Security-Policy: script-src 'self' 'nonce-XYZ'"
Rails->>NodeRenderer: "Rendering request body with railsContext.cspNonce=XYZ"
NodeRenderer->>NodeRenderer: "renderToPipeableStream(nonce=XYZ)"
NodeRenderer->>Browser: "React hydration bootstrap script nonce=XYZ"
NodeRenderer->>Browser: "RSC payload init script nonce=XYZ (injectRSCPayload)"
NodeRenderer->>Browser: "Flight payload chunk script nonce=XYZ"
NodeRenderer->>Browser: "Console-replay script nonce=XYZ"
Rails->>Browser: "Immediate-hydration script nonce=XYZ (pro_helper.rb)"
Browser->>Browser: "All nonces match header - execute scripts"
Browser->>Browser: "Hydration completes, zero CSP violations"
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsx (1)
67-71:⚠️ Potential issue | 🟠 Major | ⚡ Quick winEscape
__SSR_COMPUTATION_CACHEbefore embedding in inline script.Line 70 serializes cache data without escaping
<. If cached content contains</script>, it can break out of the tag and become executable markup.Suggested fix
dangerouslySetInnerHTML={{ __html: ` window.__APOLLO_STATE__=${JSON.stringify(initialState).replace(/</g, '\\u003c')}; - window.__SSR_COMPUTATION_CACHE=${JSON.stringify(getSSRCache())}; + window.__SSR_COMPUTATION_CACHE=${JSON.stringify(getSSRCache()).replace(/</g, '\\u003c')}; `, }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsx` around lines 67 - 71, The inline script embeds window.__SSR_COMPUTATION_CACHE using JSON.stringify(getSSRCache()) without escaping `<`, which can break out of the script tag; change the injection to escape `<` characters from the serialized cache (same approach used for initialState) before assigning to window.__SSR_COMPUTATION_CACHE so that dangerouslySetInnerHTML uses JSON.stringify(getSSRCache()).replace(/</g,'\\u003c') to sanitize the value referenced in LazyApolloGraphQLApp.server.tsx (look for dangerouslySetInnerHTML, initialState, and getSSRCache).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In
`@react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsx`:
- Around line 67-71: The inline script embeds window.__SSR_COMPUTATION_CACHE
using JSON.stringify(getSSRCache()) without escaping `<`, which can break out of
the script tag; change the injection to escape `<` characters from the
serialized cache (same approach used for initialState) before assigning to
window.__SSR_COMPUTATION_CACHE so that dangerouslySetInnerHTML uses
JSON.stringify(getSSRCache()).replace(/</g,'\\u003c') to sanitize the value
referenced in LazyApolloGraphQLApp.server.tsx (look for dangerouslySetInnerHTML,
initialState, and getSSRCache).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2ca46517-6563-4c83-8e6b-38d1e40c3c43
📒 Files selected for processing (10)
docs/pro/strict-csp.mddocs/sidebars.tsreact_on_rails_pro/spec/dummy/app/controllers/application_controller.rbreact_on_rails_pro/spec/dummy/app/views/layouts/application.html.erbreact_on_rails_pro/spec/dummy/app/views/pages/error_scenarios_hub.html.erbreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ApolloGraphQLApp.server.jsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RouterApp.server.jsxreact_on_rails_pro/spec/dummy/config/initializers/content_security_policy.rbreact_on_rails_pro/spec/dummy/e2e-tests/strict_csp.spec.ts
- strict_csp.spec.ts: wait for the streamed content (header + both Suspense branches at level 0) to be fully delivered before asserting the zero-violation baseline in the enforcement-canary test. Previously the baseline ran right after the load event, when streamed chunks could still be in transit, making the pre-canary assertion vacuous. - application_controller.rb: HTML-escape the CSP nonce before interpolating it into the script tag attribute. Safe today with Rails' base64/session generators, but the raw interpolation was a copyable pattern that a custom nonce generator could break out of. The docs page already shows only the safe helper-based patterns (javascript_tag/javascript_include_tag with nonce: true), so no docs change is needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f5a4140c30
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code Review — PR #3934: Strict CSP for Streamed RSCOverall: Good to merge. The changes are limited to the Pro dummy app, docs, and E2E tests — no gem or package runtime code is touched. The nonce plumbing is already correct in the framework; this PR proves it and documents it. Strengths
Issues / Suggestions1. The filter in 2.
3. Cached fragment nonce issue is correctly documented — one clarification worth adding The docs note that 4. Dev-only The PR notes this in the Codex decision log ("Review later: drop the filter if test bundles switch to production mode"). Consider adding a SecurityNo new attack surface introduced. The nonce injection in Test CoverageE2E suite covers: CSP header shape, policy enforcement (canary), zero-violation hydration, console-replay execution, RSC payload population, and multi-component stress test. This is comprehensive for a docs/demo PR. 🤖 Generated with Claude Code |
|
Codex advisory claim update: pushed rebased head |
- strict_csp.spec.ts: wait for the streamed content (header + both Suspense branches at level 0) to be fully delivered before asserting the zero-violation baseline in the enforcement-canary test. Previously the baseline ran right after the load event, when streamed chunks could still be in transit, making the pre-canary assertion vacuous. - application_controller.rb: HTML-escape the CSP nonce before interpolating it into the script tag attribute. Safe today with Rails' base64/session generators, but the raw interpolation was a copyable pattern that a custom nonce generator could break out of. The docs page already shows only the safe helper-based patterns (javascript_tag/javascript_include_tag with nonce: true), so no docs change is needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f5a4140 to
ba18e55
Compare
Code ReviewOverview: This PR adds strict-CSP proof-of-coverage for the nonce plumbing introduced in 16.4.0 — no gem/package runtime changes, only docs, a CSP initializer for the Pro dummy app, and a new Playwright E2E suite. The scope is well-contained and the nonce audit table in the PR description is thorough. Security
Test QualityThe Playwright spec is well-designed on the points that matter most:
One minor issue in the canary test — see inline comment below. Documentation
One small gap: Step 2 of "How the Nonce Flows" references the helper Cached-nonce findingThe decision to document rather than fix the fragment-caching stale-nonce issue is the right call for this PR. The write-up in the PR description and the Caching Caveats section in the docs are clear and give adopters enough to make an informed decision. Minor IssuesTwo small items flagged with inline comments (neither is blocking). |
|
codex-claim\nstatus: waiting_checks_review\nworker: codex\nhead: 22df7c9\nnotes: Added generated-docs fixup so llms-full stays below the split cap after the CSP/RSC docs change. Local validation: node script/generate-llms-full.mjs --check; pnpm start format.listDifferent; git diff --check origin/main..HEAD; pre-push branch RuboCop and markdown links. Waiting for rerun checks/review/finalizer. |
Code Review: PR #3934 — Strict CSP nonce propagation for streamed RSCOverall: This is a well-scoped, high-quality PR. The nonce audit table in the PR description is exemplary documentation, the E2E test design (installing the violation collector as an init script so it fires before any streamed chunk) is exactly right, and the cached-nonce finding is a valuable proactive disclosure. Scope is tightly limited to dummy app + docs — no gem/package runtime changes. Security
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: faa7c0b42b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/tests/serialize-for-inline-script.test.ts (1)
18-30: ⚡ Quick winAdd a regression test for top-level
undefinedinput.Current coverage is strong for script-closing strings, but adding a case for
serializeForInlineScript(undefined)will lock the edge behavior and prevent regressions.Test addition
describe('serializeForInlineScript', () => { it('escapes script-closing payloads while preserving JSON round trips', () => { @@ expect(JSON.parse(serialized)).toEqual(payload); }); + + it('handles top-level undefined safely', () => { + const serialized = serializeForInlineScript(undefined); + expect(serialized).toBe('null'); + expect(JSON.parse(serialized)).toBeNull(); + }); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@react_on_rails_pro/spec/dummy/tests/serialize-for-inline-script.test.ts` around lines 18 - 30, The test suite for serializeForInlineScript is missing coverage for the edge case where undefined is passed as input. Add a new test case (using it()) within the describe('serializeForInlineScript') block that calls serializeForInlineScript(undefined) and verifies the expected behavior for that edge case, ensuring the function handles undefined input correctly and consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@react_on_rails_pro/spec/dummy/tests/serialize-for-inline-script.test.ts`:
- Around line 18-30: The test suite for serializeForInlineScript is missing
coverage for the edge case where undefined is passed as input. Add a new test
case (using it()) within the describe('serializeForInlineScript') block that
calls serializeForInlineScript(undefined) and verifies the expected behavior for
that edge case, ensuring the function handles undefined input correctly and
consistently.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d6f0e7ab-375e-430f-83cc-9431d159a3b6
📒 Files selected for processing (4)
react_on_rails_pro/spec/dummy/app/controllers/application_controller.rbreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsxreact_on_rails_pro/spec/dummy/client/app/utils/serializeForInlineScript.tsreact_on_rails_pro/spec/dummy/tests/serialize-for-inline-script.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.server.tsx
- react_on_rails_pro/spec/dummy/app/controllers/application_controller.rb
|
Code review summary -- see inline comments for details. Overall this is a well-scoped tests-docs-dummy-app-only PR. Main actionable item: ApolloGraphQLApp.server.jsx should import and use serializeForInlineScript for consistency with LazyApolloGraphQLApp.server.tsx. Other findings are in inline comments. |
Code ReviewOverall: Well-executed verification-and-documentation PR. The nonce audit table is thorough, the E2E test structure is solid (violation collector installed before any page script via Findings
Comments (inline below)Two items worth a second look posted inline: an unused |
Code Review - PR 3934Overall: Well-executed PR. The nonce threading through the streaming pipeline is correct, the E2E tests are properly designed, and the documentation is unusually thorough. Three minor findings below. Finding 1 (low): script-context escaping in application_controller.rb - Lines 63 and 65 use ActiveSupport::JSON.encode which does NOT escape < > or & for script context. A value containing </script> would break out of the script tag. Inputs here are developer-controlled so real-world risk is low, but the pattern is unsafe if extended. Fix: use ERB::Util.json_escape(ActiveSupport::JSON.encode(value)) -- the Ruby-side equivalent of serializeForInlineScript on the JS side. Finding 2 (very low): Canary test toBe(1) is slightly brittle - strict_csp.spec.ts lines 196-200 poll until violations.length equals 1. If a late-streamed violation arrived simultaneously with canary injection, the count could jump to 2 and the poll would fail as a flake. toBeGreaterThanOrEqual(1) plus a check for blockedURI === inline would be more precise. Finding 3 (informational): serializeForInlineScript escaping is correct but could use a note - The < escape prevents </script> injection, > is defensive, and the Unicode line separators prevent line terminator injection. Neither & nor / need escaping because browsers parse script content as raw text. A brief comment would prevent future PRs from adding unnecessary escaping. Minor observations: sourceSnippetIncludesFlightClient correctly fails closed on fetch error. The reactOnRailsConfigOverrideChangeHandler remove-before-add guard handles Turbo revisits correctly. The preload_links_header: false callout in both the layout comment and troubleshooting docs is a good call for a common footgun. |
Code ReviewThis is a high-quality PR that fills a real gap — streamed RSC under a strict no-unsafe-inline policy had zero automated coverage and no documentation. The approach is sound throughout. What works wellCanary test design — the zero-violation assertion alone could be vacuous (the policy might not be enforcing at all). The canary test in Fail-closed eval tolerance — Escaping in Fragment caching finding — documenting the incompatibility and recommending against combining these features is the right call for now. The Escaping in One issue to fix
One docs suggestionThe troubleshooting entry for third-party |
…c-streaming * origin/main: Keep plan-pr-batch goal prompts under 4000 chars (#4025) docs(skills): file-collision check + goal-prompt size discipline for plan-pr-batch (#3914) Verify React 19.2 <Activity> with react_component (CSR + SSR-hydrate) + docs (#3938) Allow Pro RSC peer check for React 19.2 (#4026)
|
Approved by maintainer. |
…c-streaming * origin/main: Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933) Add post-merge audit scope resolver (#4029) Document continuous agent-run evaluation loop (#4028) Tools: add PR batch merge ledger (#3996) Add RSC FOUC acceptance coverage (#4033) # Conflicts: # llms-full.txt
* origin/main: Add declarative cache_tags revalidation to Pro fragment caching (revalidateTag analog) (#3964) Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy (#3934) Docs: document Control Plane cost posture for demos (#3998) # Conflicts: # docs/.llms-exclusions
…ter-slice * origin/main: Add useRailsForm hook + render_model_errors concern: Inertia useForm-style Rails bridge (#3872) (#3942) Add declarative cache_tags revalidation to Pro fragment caching (revalidateTag analog) (#3964) Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy (#3934) Docs: document Control Plane cost posture for demos (#3998) Add built-in /health and /ready endpoints to the Pro node renderer (#3939) Document capacity-aware triage contracts (#4027)
…ce-maps * origin/main: Add TanStack Router instant-navigation starter (Pro dummy) + docs guide (#3873 v1 slice) (#3953) Add useRailsForm hook + render_model_errors concern: Inertia useForm-style Rails bridge (#3872) (#3942) Add declarative cache_tags revalidation to Pro fragment caching (revalidateTag analog) (#3964) Verify and document CSP-nonce propagation for streamed RSC under a strict no-unsafe-inline policy (#3934) Docs: document Control Plane cost posture for demos (#3998) Add built-in /health and /ready endpoints to the Pro node renderer (#3939) Document capacity-aware triage contracts (#4027)
## Summary Stamps the **17.0.0.rc.4** changelog header and reconciles the post-rc.3 entries. ### What changed - **Stamped `### [17.0.0.rc.4] - 2026-06-14`** with the standard compare-link updates (`v17.0.0.rc.3...v17.0.0.rc.4`, `unreleased → rc.4...main`). Prior RC sections are left intact per the prerelease convention. - **Moved the source-mapped stack traces entry (PR 3940) into rc.4.** It had been placed inside the already-tagged `rc.3` section, but `#3940` merged *after* `v17.0.0.rc.3` was tagged (verified: `d9ac060a0` is not an ancestor of the rc.3 tag). - **Added the missing `[PR 4026]` attribution** to the RSC peer-compatibility (React 19.2 floor) entry, which previously linked only the issue. - **Merged the two duplicate `#### Added` headings** in the section into one. ### rc.4 contents - **Added**: cache_tags revalidation (#3964, Pro), React 19 root error callbacks (#3933), `useRailsForm` + `render_model_errors` (#3942), node-renderer `/health` & `/ready` endpoints (#3939, Pro), source-mapped stack traces (#3940, Pro) - **Changed**: RSC peer compatibility for the React 19.2 floor (#4026, Pro) - **Fixed**: Rspack generated apps start in HMR mode (#3926) Other merged PRs since rc.3 were docs / CI / tooling / test-infra and were intentionally not given entries (e.g. #3949 only touches `spec/support`; the user-facing generator hook is explicitly unaffected; #3953 adds zero runtime surface; #3934/#3938 are verify+docs). ### Notes - Verified clean under the repo's pinned `prettier@3.6.2`. The local pre-commit hook could not run prettier (binary not installed in this workspace), so the commit used `--no-verify`; CI prettier will run normally. After merge, run `rake release` (no args) to publish `v17.0.0.rc.4` and auto-create the GitHub release from this section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Documentation-only changelog edits with no runtime or API surface changes. > > **Overview** > Adds the **`17.0.0.rc.4` (2026-06-14)** release section to `CHANGELOG.md` and updates the bottom compare links so **`[unreleased]`** points at `v17.0.0.rc.4...main` and **`[17.0.0.rc.4]`** covers `v17.0.0.rc.3...v17.0.0.rc.4`. > > The edit **re-sorts notes that landed after the rc.3 tag**: the Pro **source-mapped Node renderer stack traces** entry moves out of the rc.3 block into rc.4, and the duplicate **`#### Added`** under the RSC peer-compatibility **Changed** item is folded into a single Added list (health/ready probes stay documented under rc.4 Added). The React **19.2 floor** peer-compat line gains **[PR 4026]** attribution alongside the existing issue link. > > No application or library code changes—release documentation only. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 40c7a88. 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 * **New Features** * Introduced `useRailsForm` hook for improved form handling * Added `/health` and `/ready` endpoints to Node renderer for better observability * Expanded React 19 support with root error callback registration * Enhanced Pro cache tag revalidation capabilities * **Changed** * Improved error diagnostics with source-mapped stack traces in Node renderer * Extended RSC peer compatibility for React 19.2.7+ <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual verification: coverage/docs PR — confirmed scope, ran the new unit coverage ✅ (E2E proof not run here)This PR (Fixes #3879) is a proof + coverage + docs change: the CSP-nonce plumbing for streamed RSC shipped back in 16.4.0 (PRs #2398/#2418); this adds a strict-CSP enforcement in the Pro dummy app, a Playwright E2E proving zero violations, a serialization helper + unit test, and docs. Its own summary states "No gem/package code changes were needed." What I verified
CaveatThe authoritative proof here is the Playwright E2E under the enforced strict policy, which needs a built Pro dummy app + running node renderer + browser — I did not run it in this environment. I verified the PR's scope claim (no product code change) and the new serialization unit test; the end-to-end "zero CSP violations on streamed RSC pages" assertion is verified by the committed E2E in CI ( |
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:
script-src 'self'+ per-request nonce, NOunsafe-inline; development-only carve-outs for webpack-dev-server/HMR/eval) via the newreact_on_rails_pro/spec/dummy/config/initializers/content_security_policy.rb.react_on_rails_pro/spec/dummy/e2e-tests/strict_csp.spec.ts): loads streamed RSC pages under the enforced policy with asecuritypolicyviolationlistener 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 existingdummy-app-node-renderer-e2e-testsCI job — no workflow changes.docs/pro/strict-csp.md: Rails initializer recipe, nonce flow (Railscontent_security_policy_nonce→railsContext.cspNonce→ node renderer → injected<script nonce>), what is/isn't nonce-covered and whytype="application/json"data tags need no nonce, caching caveats, troubleshooting.preload_links_header: false— preload headers can't carry nonces), replace inlineonchangehandlers with a nonced script, nonce the committed-stream error script, and threadrailsContext.cspNonceinto the Apollo__APOLLO_STATE__tags and React Router'sStaticRouterProviderhydration 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
packages/react-on-rails-pro/src/injectRSCPayload.ts:56-61,346sanitizeNonce(cspNonce))injectRSCPayload.ts:63-65,367REACT_ON_RAILS_RSC_PAYLOADSpopulated under enforced policyinjectRSCPayload.ts:78-91,361injectRSCPayload.ts:371-374[SERVER]log appears in browser consoleinjectRSCPayload.ts:48-54; accept at:123-125createScriptTagcall threadssanitizedNonce$RC/$RXruntime scriptspackages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts:118(nonce:option),:95(cspNonce → injectRSCPayload)$RXscript nonced; E2E hydration completesreact_on_rails/lib/react_on_rails/pro_helper.rb:27-31csp_nonce)pro_helper.rb:52-60react_on_rails/lib/react_on_rails/helper.rb:554-565id="consoleReplayLog"+ nonce)pro_helper.rb:10-21type="application/json"data block — browser never executes;script-srcdoesn't apply)helper.rb:584-587pro_helper.rb:43-46sanitizeNoncefalse-drop checkpackages/react-on-rails/src/sanitizeNonce.ts+,/, leading+, and==padding (e.g.+k12QYROWcdDhxenkVkpHQ==) passed through and hydrated in E2E; regex permits full base64/base64url +={0,2}Gaps found and fixed (dummy-app level, not gem): committed-stream error script (
application_controller.rb), Apollo__APOLLO_STATE__tags, React RouterStaticRouterProviderhydration 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_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 — underreact_component_cache_key, which does not include the nonce;handle_stream_cache_hitreplays those chunks verbatim. Verified empirically in the dummy with a memory store: a cache hit (0.04s) servednonce="Q16Z/…"inside the body while the response header carried a freshnonce-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_cachingnever serves a stale nonce but is silently defeated: its cache key digests the rendering request js code, which embedsrailsContext.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.:null_store, and:caching-tagged specs use a fresh per-example MemoryStore with a single visit.Test plan
--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 --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
SecureRandom.base64(16)nonce;nonce_directives = %w[script-src];style-srckeepsunsafe-inline.blockedURI:"eval"from thereact-on-rails-rscdevelopment Flight client (createFakeFunctioncalls(0, eval)for server stack frames; try/catch-guarded; production build has 0 eval call sites, verified in dist).addEventListenerinstead of inlineonchange, nonced Apollo/Router/committed-stream scripts).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
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 andllms-full-pro.txt) describing how to run streamed SSR/RSC underscript-srcwithout'unsafe-inline', including Rails nonce setup, nonce flow throughrailsContext.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.rbinitializer (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 withpreload_links_header: false,javascript_tag nonce: trueinstead of inlineonchange, nonced late-stream error redirect script with safer escaping, andcspNonceon Apollo inline state tags andStaticRouterProvider.Adds
serializeForInlineScriptplus 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 unexpectedsecuritypolicyviolationevents (tolerating dev-only Flightevalnoise).No gem/package runtime changes in this diff—the work documents and continuously verifies existing nonce plumbing.
Reviewed by Cursor Bugbot for commit 3805069. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
Documentation
New Features
'unsafe-inline'.Tests
Merge Readiness Criteria
Current evaluation as of 2026-06-15 for head
38050696a14188ee27a9c9a53e00cbad40b59a9b.accelerated-rc, from canonical release gate Release gate: react_on_rails 17.0.0 #3823 (Agent Release Modeblock verified live on 2026-06-15).38050696a14188ee27a9c9a53e00cbad40b59a9b.origin/mainwas merged into the PR branch; generatedllms-full.txt/llms-full-pro.txtwere verified after the merge update.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.mergeStateStatusisCLEAN.unresolved=0immediately before merge.mainand regenerated/verified generated LLMS output; no Bump react-hooks lint to v6 and document RSC compiler boundary #3963-reserved hooks v6 / React Compiler / RSC compiler-boundary docs were touched.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-linksprior readiness run -> passed; OSS RuboCop prior readiness run -> passed; pre-push hook prior readiness run -> passed.Agent Merge Confidence
Mode: accelerated-rc
Current head SHA:
38050696a14188ee27a9c9a53e00cbad40b59a9bScore: 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.Review/check gate:
38050696a14188ee27a9c9a53e00cbad40b59a9b; skips are path-selected helper skips.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
justin808says “Approved by maintainer.”Confidence note:
gh pr checks 3934-> 25 pass / 9 skip / 0 pending / 0 failed; GraphQL review threads -> 42 total / 0 unresolved;mergeStateStatus->CLEAN.