-
-
Notifications
You must be signed in to change notification settings - Fork 630
Add rsc-guardrails agent skill + payload-escaping regression test + advisory hook #4599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
b572036
Add rsc-guardrails skill, payload-escaping regression test, and advis…
justin808 9477052
Anchor rsc-guardrails hook matching on the absolute path (symlink-rob…
justin808 3470143
Close RSC guardrail review gaps
justin808 78b2b95
Keep RSC guardrail scans focused
justin808 e9a3813
Merge branch 'main' into jg/rsc-agent-guardrails-skill
justin808 9a15d33
Catch compound innerHTML sinks without equality noise
justin808 acedaa0
Merge remote-tracking branch 'origin/main' into jg/rsc-agent-guardrai…
justin808 26e4a86
Tighten RSC guardrail sink ownership
justin808 c0739f7
Tighten raw HTML sink detection
justin808 aa99133
Address rsc-guardrails review threads: type-only false positives, wor…
justin808 e2d857f
Merge branch 'main' into jg/rsc-agent-guardrails-skill
justin808 cf059d2
rsc-guardrails: detect prettier-split multiline dangerouslySetInnerHT…
justin808 266bb86
rsc-guardrails: close literal-prefix + document.writeln sink bypasses…
justin808 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| --- | ||
| 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) | ||
|
|
||
| 1. **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. | ||
|
|
||
| 2. **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. | ||
|
|
||
| 3. **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. | ||
|
|
||
| 4. **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]). | ||
|
|
||
| 5. **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]). | ||
|
|
||
| 6. **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 | ||
|
|
||
| - [ ] New TypeScript inline script/markup emission routes through `createScriptTag`/`escapeScript` | ||
| (and nonce via `sanitizeNonce`); 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`/`import` of 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: Infinity` without 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 `include`ing 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`. | ||
|
|
||
| [#4595]: https://github.com/shakacode/react_on_rails/issues/4595 | ||
| [#4596]: https://github.com/shakacode/react_on_rails/issues/4596 | ||
| [#4597]: https://github.com/shakacode/react_on_rails/issues/4597 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.