| name | rsc-guardrails |
| description | Use when adding, editing, or reviewing React Server Components (RSC) code in React on Rails or React on Rails Pro — inline payload injection and streaming, the Node renderer, client-reference/manifest resolution, RSC controllers and routes, or props/console/error serialization. Covers the security and cross-request-isolation invariants that RSC changes must not regress.
|
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 through createScriptTag → escapeScript.
Never hand-build a <script>…</script> string that contains flight-payload bytes, props, or any
user-derived data. escapeScript (in packages/react-on-rails-pro/src/injectRSCPayload.ts)
neutralizes </script and <!--; combined with JSON.stringify it 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 through sanitizeNonce before landing in an attribute.
The sanctioned Ruby stream emitter in
react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb uses the language-specific
equivalent: ERB::Util.json_escape for JavaScript values and ERB::Util.html_escape for 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 React useRef, 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;
vm is 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
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 *_route helper with no before_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 via wrapInScriptTags in
packages/react-on-rails/src/RenderUtils.ts and consumed by helper.rb's console-replay
(content_tag(:script, console_script_code.html_safe, ...)). Note the asymmetry: scriptSanitizedVal
neutralizes 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 of escapeScript, 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-rsc npm 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.