Add CSP nonce support to RSC streaming and console replay scripts#2418
Add CSP nonce support to RSC streaming and console replay scripts#2418
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:
WalkthroughThreads an optional CSP nonce from Rails through the Node RSC renderer into injected/preloaded RSC payload scripts and client-side console-replay scripts; nonce values are sanitized before being assigned to generated Changes
Sequence Diagram(s)sequenceDiagram
participant Rails as Rails Server
participant Node as Node Renderer
participant Client as Browser Client
Rails->>Rails: extract csp_nonce
Rails->>Node: railsContext { ..., cspNonce }
Node->>Node: createFromFetch / createFromPreloadedPayloads(..., cspNonce)
Node->>Node: injectRSCPayload(stream, tracker, domNodeId, cspNonce)
Node->>Client: send HTML + inline RSC payload scripts (nonce attr)
Client->>Client: transformRSCStreamAndReplayConsoleLogs(stream, cspNonce)
Client->>Client: sanitizeNonce(cspNonce)
Client->>Client: create replay <script nonce="..."> and append -> executes
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 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 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 |
Greptile SummaryThis PR propagates CSP (Content Security Policy) nonce values through the React on Rails Pro RSC streaming and console replay paths, ensuring that dynamically injected
Confidence Score: 4/5
Important Files Changed
Flowchartflowchart TD
A["Rails helper.rb\n(rails_context)"] -->|"cspNonce"| B["RailsContext type\n(types/index.ts)"]
B -->|"Server-side path"| C["streamServerRenderedReactComponent"]
C -->|"railsContext.cspNonce"| D["injectRSCPayload"]
D -->|"nonceAttribute(cspNonce)"| E["Script tags with nonce\n(RSC payload injection)"]
B -->|"Client-side path"| F["getReactServerComponent.client"]
F -->|"railsContext.cspNonce"| G["transformRSCStreamAndReplayConsoleLogs"]
G -->|"sanitizeNonce(cspNonce)"| H["Script elements with nonce\n(console replay)"]
Last reviewed commit: fe948e6 |
size-limit report 📦
|
62b60f1 to
0fd9d96
Compare
|
PR Review: CSP nonce support for RSC streaming. The approach is solid overall -- threading cspNonce through Rails context into both server-side HTML stream injection and client-side console replay is the right pattern. The Ruby side is clean; csp_nonce is already well-guarded by the existing respond_to? check and Rails version fallback at helper.rb:493. See inline comments for specific issues found. |
| return error instanceof Error ? error.message : String(error); | ||
| }; | ||
|
|
||
| export const sanitizeNonce = (nonce?: string) => nonce?.replace(/[^a-zA-Z0-9+/=_-]/g, ''); |
There was a problem hiding this comment.
The regex /[^a-zA-Z0-9+/=_-]/g keeps = (it is a valid Base64 padding character and is explicitly in the allow-list). However both new test files pass inputs like 'abc123" onload=alert(1)' and assert that the = is stripped, producing abc123onloadalert1. The actual output will be abc123onload=alert1 because = survives the regex, causing all three sanitization assertions to fail.
Two ways to resolve this, depending on intent:
Option A — keep = (correct for real Base64 nonces), fix the tests:
Update test expected values to abc123onload=alert1 and abc123onclick=alert1, and remove the not.toContain('onload=') assertion (the sanitized string still contains onload= as part of the nonce value, but that is harmless since the " closing the attribute has already been stripped).
Option B — strip = too, fix the regex:
| export const sanitizeNonce = (nonce?: string) => nonce?.replace(/[^a-zA-Z0-9+/=_-]/g, ''); | |
| export const sanitizeNonce = (nonce?: string) => nonce?.replace(/[^a-zA-Z0-9+/_-]/g, ''); |
Note: stripping = would break nonces with Base64 padding, so Option A is generally safer for real-world nonces.
| const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId, 'abc123" onload=alert(1)'); | ||
| const resultStr = await collectStreamData(result); | ||
|
|
||
| expect(resultStr).toContain('<script nonce="abc123onloadalert1">'); |
There was a problem hiding this comment.
This assertion will fail. sanitizeNonce allows = (it is in the regex allow-list), so the input 'abc123" onload=alert(1)' sanitizes to abc123onload=alert1, not abc123onloadalert1.
If the intent is to keep = (the right choice for Base64 nonces), this line and the next assertion need to be updated:
| expect(resultStr).toContain('<script nonce="abc123onloadalert1">'); | |
| expect(resultStr).toContain('<script nonce="abc123onload=alert1">'); |
| const resultStr = await collectStreamData(result); | ||
|
|
||
| expect(resultStr).toContain('<script nonce="abc123onloadalert1">'); | ||
| expect(resultStr).not.toContain('onload='); |
There was a problem hiding this comment.
This assertion will also fail. The sanitized nonce is abc123onload=alert1, and the resulting HTML is <script nonce="abc123onload=alert1">, which does contain the substring onload=. However, this is not a security issue — the " that would close the attribute has been stripped, so there is no attribute injection. The check should be for the real injection vector instead:
| expect(resultStr).not.toContain('onload='); | |
| expect(resultStr).not.toContain('onload=alert'); |
| await collectStream(transformedStream); | ||
|
|
||
| const script = document.body.querySelector('script'); | ||
| expect(script?.getAttribute('nonce')).toBe('abc123onclickalert1'); |
There was a problem hiding this comment.
Same regex issue — = is kept by sanitizeNonce, so 'abc123" onclick=alert(1)' produces abc123onclick=alert1, not abc123onclickalert1. This assertion will fail.
| expect(script?.getAttribute('nonce')).toBe('abc123onclickalert1'); | |
| expect(script?.getAttribute('nonce')).toBe('abc123onclick=alert1'); |
AbanoubGhadban
left a comment
There was a problem hiding this comment.
LGTM. One optional nit: the sanitization regex allows = anywhere in the string, while the CSP3 spec only permits it as trailing padding. Using /^[a-zA-Z0-9+/_-]+={0,2}$/ would be spec-exact, but it's not a security concern — just conformance.
Moves the nonce sanitization regex from injectRSCPayload.ts and transformRSCStreamAndReplayConsoleLogs.ts into a shared utility in utils.ts so the allowed character set is defined in one place. Co-Authored-By: Claude Opus 4.6 <[email protected]>
0fd9d96 to
51ae4fd
Compare
Code ReviewOverall this is a solid, well-structured addition. CSP nonce support threads cleanly through both the Ruby and TypeScript layers, the sanitization logic is sound, and the defence-in-depth approach (strip + format-validate) is the right pattern for nonces. A few items worth addressing before merge are noted below with inline comments. Summary of findingsCorrectness
Test gaps
Performance / design
Minor
|
- Add happy-path test for valid nonce in injectRSCPayload
- Use script.nonce IDL property instead of getAttribute('nonce') in test
- Memoize sanitizeNonce call once at injectRSCPayload entry point
- Add cross-package sync comment in RenderUtils.ts
Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
test comment - please ignore (deleted) |
| expect(resultStr).not.toContain('nonce='); | ||
| expect(resultStr).not.toContain('onload='); | ||
| }); | ||
|
|
There was a problem hiding this comment.
The test is correct but the reason why the nonce is rejected may not be obvious to future readers. After stripping the disallowed characters (", space, (, )), the input becomes abc123onload=alert1. This then fails the base64 format regex because = appears in the middle of the string rather than only as trailing padding — so the whole nonce is rejected.
A short comment would make this clearer:
| // The malicious nonce, after stripping disallowed chars (quotes, parens, spaces), | |
| // becomes "abc123onload=alert1". This fails the base64 format check because '=' | |
| // appears mid-string rather than as trailing padding, so the nonce is rejected entirely. | |
| const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId, 'abc123" onload=alert(1)'); |
|
|
||
| export default function transformRSCStreamAndReplayConsoleLogs( | ||
| stream: ReadableStream<Uint8Array | string>, | ||
| cspNonce?: string, |
There was a problem hiding this comment.
Nit: there's an extra blank line here that was added by this PR (between the JSDoc block and export default function). The blank line is unnecessary.
| cspNonce?: string, | |
| export default function transformRSCStreamAndReplayConsoleLogs( |
- Stamp version header for 16.4.0.rc.7 - Collapse rc.6 section into rc.7 (single prerelease section) - Add new entries: #2418 (CSP nonce for RSC), #2421 (babel preset), #2581 (doctor false positives) - Remove duplicate PR 2407 entry (appeared in both sections) - Update version diff links Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
cspNoncetorails_contextso nonce data is available in both server-side and client-side RSC flowsinjectRSCPayload)transformRSCStreamAndReplayConsoleLogs)Closes #2401
Test plan
bundle exec rspec react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rbpnpm -C packages/react-on-rails-pro test -- injectRSCPayload.test.tspnpm -C packages/react-on-rails-pro test -- transformRSCStreamAndReplayConsoleLogs.test.ts2.6.10(project requires>= 3.0.0)pnpmis not installed in this environmentSummary by CodeRabbit
New Features
Security
Tests
Note
Medium Risk
Touches streaming HTML/script injection paths and Rails context shaping; while behavior is mostly additive, mistakes could break hydration/streaming or CSP compliance.
Overview
Adds end-to-end CSP nonce support for React Server Components by exposing
cspNoncein the Railsrails_contextand threading it through both server and client RSC flows.Server streaming now injects RSC payload
<script>tags with a sanitizednonce(viainjectRSCPayloadand its callerstreamServerRenderedReactComponent), and client-side RSC rendering/replayed console scripts also apply the sanitized nonce (viatransformRSCStreamAndReplayConsoleLogsandgetReactServerComponent.client). New unit/spec tests cover nonce propagation and sanitization to prevent attribute injection.Written by Cursor Bugbot for commit 62b60f1. Configure here.