RSC Guardrails (framework internals)
RSC spans a trusted server → untrusted client boundary. A handful of invariants keep that boundary safe. Most live in one owner file each; the risk is a well-meaning change quietly breaking one. Preserve every invariant below, and when you add a new surface, add its guardrail in the same change. When in doubt, the audit issues #4595, #4596, #4597 are the worked examples.
Invariants (do not regress)
Inline
<script>/ payload emission goes throughcreateScriptTag→escapeScript. Never hand-build a<script>…</script>string that contains flight-payload bytes, props, or any user-derived data.escapeScript(inpackages/react-on-rails-pro/src/injectRSCPayload.ts) neutralizes</scriptand<!--; combined withJSON.stringifyit blocks HTML/JS breakout. A payload chunk is user-controlled (usernames, comments). Regression test:packages/react-on-rails-pro/tests/injectRSCPayload.test.ts(breakout-escaping case). Nonces must pass throughsanitizeNoncebefore landing in an attribute. The sanctioned Ruby stream emitter inreact_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rbuses the language-specific equivalent:ERB::Util.json_escapefor JavaScript values andERB::Util.html_escapefor the CSP nonce. Preserve those helpers and their stream specs rather than replacing them with the TypeScript helpers.No per-request data in module scope on server-render paths. On the Node SSR server, a module-level
let/Map/Set/object is shared across all concurrent users' requests. Per-request RSC state (payloads, props, trackers, caches) must be request-scoped: instantiate per render (e.g.new RSCRequestTracker(...)), hold it in ReactuseRef, or thread it through arguments — never a module-level mutable singleton. Module scope is only for build-config caches keyed by a build artifact (e.g. manifest-by-filename). See PRs #4574 / #4550 for what breaks this.Module / client-reference resolution stays on the allow-list. Resolve a flight-payload module ID only through React's webpack/rspack manifest (the build-time allow-list). Never
require()/import()a path, name, or specifier derived from payload or request content. The unbundled decode path is deliberately disabled (it throws) — keep it that way.Every client-reachable render entrypoint needs an auth story, and request props are untrusted. A route/controller that renders a component from
params(name or props) is a public API unless the host app adds authentication. Server components must derive identity from the Rails session/railsContext, never from props. When you add such an entrypoint, ship an auth hook + a docs warning (#4595).Keep secrets and PII out of logs and error context. Do not log the renderer password, the
renderer_url(it may embed credentials), the auth password, or raw props/js_code. Mask before logging; offer redaction for error-tracker context (#4597).The Node renderer is RCE-by-design once authenticated (it runs client-supplied JS via
vm;vmis not a sandbox). The trust boundary is shared password + network isolation. Don't widen it: keep the default bind private, keep the production password requirement (process.exit(1)without one), authenticate before any bundle write/execute, and don't add unauthenticated routes that read files or run code (#4596).
PR review checklist
- New TypeScript inline script/markup emission routes through
createScriptTag/escapeScript(and nonce viasanitizeNonce); the sanctioned Ruby stream emitter preserves its Rails escaping helpers. - No new module-level mutable state holds per-request/per-user data on a server-render path.
- No
require/importof a payload- or request-derived module id/path. - New render route/controller has an auth story; server components don't trust props for authz.
- No secret/PII (password,
renderer_url, props,js_code) added to logs or error context. - New Node-renderer route authenticates before doing file/exec work; no
fileSize: Infinitywithout a cap.
Red flags — stop and reconsider
- Building a
`<script>${something}</script>`string by hand in RSC/streaming code. let x = new Map()(or= {}/= []) at module top-level in a file that runs during server render.require(/import(with a variable that traces back to a request, payload, or manifest value.- A new controller
includeing an RSC renderer or a*_routehelper with nobefore_action. logger.*("… #{password | renderer_url | props | js_code} …").
Reference
- Escaping:
packages/react-on-rails-pro/src/injectRSCPayload.ts(escapeScript,createScriptTag),packages/react-on-rails/src/sanitizeNonce.ts. - Base-package escaping (same trusted-server → untrusted-client boundary, non-RSC):
packages/react-on-rails/src/scriptSanitizedVal.ts, used viawrapInScriptTagsinpackages/react-on-rails/src/RenderUtils.tsand consumed byhelper.rb's console-replay (content_tag(:script, console_script_code.html_safe, ...)). Note the asymmetry:scriptSanitizedValneutralizes only</script-like sequences (/<\/\W*script/gi), not<!--, because its input is the server-generated console-replay payload rather than arbitrary RSC flight bytes; do not assume it is a drop-in equal ofescapeScript, and tighten it if its input surface ever widens. - Ruby stream escaping:
react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb(ERB::Util.json_escape,ERB::Util.html_escape). - Request-scoping:
packages/react-on-rails-pro/src/RSCRequestTracker.ts,RSCProvider.tsx,RSCPrefetchStore.ts. - Reference resolution: the separate
react-on-rails-rscnpm package/repository (WebpackLoader,WebpackPlugin,RspackPlugin, and the manifest-backed client/server runtime). This monorepo consumes that package rather than vendoring its source. - Node renderer:
packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts,worker.ts,worker/vm.ts,shared/configBuilder.ts. - Ruby serialization:
react_on_rails/lib/react_on_rails/json_output.rb(ERB::Util.json_escape),helper.rb.