1"use strict";(self.webpackChunkgatsby_starter_default=self.webpackChunkgatsby_starter_default||[]).push([[3152],{4947:function(e,t,n){n.r(t),n.d(t,{Head:function(){return g},default:function(){return m}});var a=n(28453),r=n(96540);function l(e){const t=Object.assign({h1:"h1",a:"a",span:"span",div:"div",p:"p",svg:"svg",path:"path",strong:"strong",ul:"ul",li:"li",h2:"h2",h3:"h3",em:"em",blockquote:"blockquote",table:"table",thead:"thead",tr:"tr",th:"th",tbody:"tbody",td:"td",ol:"ol"},(0,a.R)(),e.components);return r.createElement(r.Fragment,null,r.createElement(t.h1,{id:"security-model-and-hardening",style:{position:"relative"}},r.createElement(t.a,{href:"#security-model-and-hardening","aria-label":"security model and hardening permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Security Model and Hardening"),"\n",r.createElement(t.div,{className:"markdown-alert markdown-alert-note",dir:"auto"},"\n",r.createElement(t.p,{className:"markdown-alert-title",dir:"auto"},r.createElement(t.svg,{className:"octicon",viewBox:"0 0 16 16",width:"16",height:"16","aria-hidden":"true"},r.createElement(t.path,{d:"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"})),"NOTE"),"\n",r.createElement(t.p,null,r.createElement(t.strong,null,"Summary for AI agents:")," This page documents the React on Rails security model: how component props are escaped into HTML, the trust boundary and hardening options for the Pro Node renderer, and the React Server Components (RSC) advisory posture. For review-app-specific guidance, see ",r.createElement(t.a,{href:"/linked-assets/7351d0b7fb98f9692df4cdced5b4f441/review-app-security.md"},"Review App Security"),". To report a vulnerability, see ",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails/blob/main/SECURITY.md"},"SECURITY.md"),"."),"\n"),"\n",r.createElement(t.p,null,"This guide is written for security reviewers. Every claim is tied to specific code in the\n",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails"},"shakacode/react_on_rails")," repository so you can verify it\nyourself; where a guarantee does ",r.createElement(t.strong,null,"not")," exist, that is stated explicitly."),"\n",r.createElement(t.p,null,"Scope:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"#props-escaping-contract"},"Props escaping contract")," â applies to all React on Rails apps (OSS and Pro)."),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"#node-renderer-threat-model-pro"},"Node renderer threat model")," â applies to React on Rails Pro apps using the\nstandalone Node renderer."),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"#react-server-components-advisory-posture-pro"},"React Server Components advisory posture")," â applies to Pro\napps using RSC."),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"#reporting-vulnerabilities"},"Reporting vulnerabilities"),"."),"\n"),"\n",r.createElement(t.p,null,"Out of scope here: the general security of your own React components and Rails app (authentication,\nauthorization, CSRF, etc. remain standard Rails concerns â React on Rails does not add a separate\nauthentication or authorization layer in front of your Rails app)."),"\n",r.createElement(t.h2,{id:"props-escaping-contract",style:{position:"relative"}},r.createElement(t.a,{href:"#props-escaping-contract","aria-label":"props escaping contract permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Props escaping contract"),"\n",r.createElement(t.h3,{id:"how-props-reach-the-page",style:{position:"relative"}}
1,r.createElement(t.a,{href:"#how-props-reach-the-page","aria-label":"how props reach the page permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"How props reach the page"),"\n",r.createElement(t.p,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_component</code>'}})," and ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">redux_store</code>'}})," embed your props as JSON inside\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><script type="application/json"></code>'}})," data tags â not as executable JavaScript:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,"Component props: ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">generate_component_script</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/pro_helper.rb</code>'}}),"\n(part of the OSS gem despite the file name) builds\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">content_tag(:script, json_safe_and_pretty(render_options.client_props).html_safe, type: "application/json", ...)</code>'}}),"."),"\n",r.createElement(t.li,null,"Store props: ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">generate_store_script</code>'}})," in the same file does the same for Redux store hydration data."),"\n",r.createElement(t.li,null,"Rails context: ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">rails_context_if_not_already_rendered</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/helper.rb</code>'}}),"\nembeds the rails context the same way."),"\n"),"\n",r.createElement(t.p,null,"The client-side package reads these tags back with ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">JSON.parse</code>'}})," and passes the result to your components."),"\n",r.createElement(t.h3,{id:"what-is-escaped-and-by-what",style:{position:"relative"}},r.createElement(t.a,{href:"#what-is-escaped-and-by-what","aria-label":"what is escaped and by what permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"What is escaped, and by what"),"\n",r.createElement(t.p,null,"All three paths above escape the JSON through one function:\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ReactOnRails::JsonOutput.escape</code>'}}),", which delegates to Rails'\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ERB::Util.json_escape</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/json_output.rb</code>'}}),", line 8)."),"\n",r.createElement(t.p,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ERB::Util.json_escape</code>'}})," replaces the HTML-significant characters ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><</code>'}}),", ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">></code>'}}),", and ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">&</code>'}})," with their Unicode\nescape sequences (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">\\u003c</code>'}}),", ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">\\u003e</code>'}}),", ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">\\u0026</code>'}}),") and escapes the U+2028/U+2029 line separators. Because no\nliteral ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><</code>'}})," can survive, a props value containing ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:"<code class=\"language-text\"></script><script>alert('xss')</script></code>"}})," cannot terminate\nthe surrounding ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><script></code>'}})," tag â it is emitted as\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:"<code class=\"language-text\">\\u003c/script\\u003e\\u003cscript\\u003ealert('xss')\\u003c/script\\u003e</code>"}}),", which ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">JSON.parse</code>'}})," later restores\nto the original string for your component as plain data."),"\n",r.createElement(t.p,null,"The helper entry points that apply this escaping:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">sanitized_props_string</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/helper.rb</code>'}}),") â escapes props whether passed\nas a ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">
1Hash</code>'}})," (serialized via ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">to_json</code>'}})," first) or as a pre-serialized JSON ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">String</code>'}}),"."),"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">json_safe_and_pretty</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/helper.rb</code>'}}),") â same contract, used by the\nscript-tag generators above."),"\n"),"\n",r.createElement(t.h3,{id:"test-coverage",style:{position:"relative"}},r.createElement(t.a,{href:"#test-coverage","aria-label":"test coverage permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Test coverage"),"\n",r.createElement(t.p,null,"This contract is covered by specs in the repository:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/spec/react_on_rails/json_output_spec.rb</code>'}})," â unit specs for ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">JsonOutput.escape</code>'}}),", including\nan explicit ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"></script><script>...</code>'}})," payload."),"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">#sanitized_props_string</code>'}}),"\nexamples) â asserts that a props hash containing ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:"<code class=\"language-text\"></script><script>alert('foo')</script></code>"}})," is escaped in\nthe rendered output, for both hash and string-typed props."),"\n"),"\n",r.createElement(t.h3,{id:"what-this-does-and-does-not-guarantee",style:{position:"relative"}},r.createElement(t.a,{href:"#what-this-does-and-does-not-guarantee","aria-label":"what this does and does not guarantee permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"What this does and does not guarantee"),"\n",r.createElement(t.p,null,r.createElement(t.strong,null,"Guaranteed (by the code above):")),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,"Props values cannot break out of the JSON ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><script type="application/json"></code>'}})," data tags, because ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text"><</code>'}}),",\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">></code>'}}),", and ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">&</code>'}})," never appear literally in the emitted JSON."),"\n",r.createElement(t.li,null,"Props are delivered to React as data. React's normal JSX text rendering then escapes them again on\noutput, as in any React app."),"\n"),"\n",r.createElement(t.p,null,r.createElement(t.strong,null,"Not guaranteed â your responsibility:")),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"What your components do with props.")," If a component passes a props value to\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">dangerouslySetInnerHTML</code>'}}),", interpolates it into a URL (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">javascript:</code>'}})," schemes), or otherwise treats it as\nmarkup, the embedding-layer escaping above does not protect you. Escaping happens at the HTML-embedding\nboundary, not inside your component tree."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Pre-serialized string props are trusted as JSON.")," If you pass props as a ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">String</code>'}})," instead of a\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">
1Hash</code>'}}),", React on Rails HTML-escapes it for the client-side script tag but does ",r.createElement(t.strong,null,"not")," validate that it\nis well-formed JSON. On the server-rendering path, the string is interpolated directly into the\nJavaScript evaluated by the SSR runtime (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">var props = #{props_string};</code>'}})," in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/server_rendering_js_code.rb</code>'}}),"; see also the comment above the\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">js_code</code>'}})," construction in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">server_rendered_react_component</code>'}})," in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/helper.rb</code>'}}),", which only escapes U+2028/U+2029 there). A string built\nby concatenating untrusted input is therefore JavaScript injection into your own SSR context. ",r.createElement(t.strong,null,"Build\nprops from Ruby data structures (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">Hash</code>'}}),"); never concatenate user input into a JSON string yourself.")),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Server-rendered HTML is inserted as-is.")," The HTML your server-rendered components produce is marked\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">html_safe</code>'}})," and inserted without further sanitization â the output of your own bundle is trusted by\ndesign. The escaping contract covers props going ",r.createElement(t.em,null,"in"),", not component HTML coming ",r.createElement(t.em,null,"out"),"."),"\n"),"\n",r.createElement(t.h2,{id:"node-renderer-threat-model-pro",style:{position:"relative"}},r.createElement(t.a,{href:"#node-renderer-threat-model-pro","aria-label":"node renderer threat model pro permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Node renderer threat model (Pro)"),"\n",r.createElement(t.p,null,"This section applies to React on Rails Pro deployments using the standalone Node renderer\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-on-rails-pro-node-renderer</code>'}}),"). If you use ExecJS-based SSR (the OSS default), there is no separate\nrenderer process or network hop, and this section does not apply."),"\n",r.createElement(t.h3,{id:"trust-model-the-renderer-executes-your-apps-code",style:{position:"relative"}},r.createElement(t.a,{href:"#trust-model-the-renderer-executes-your-apps-code","aria-label":"trust model the renderer executes your apps code permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Trust model: the renderer executes your app's code"),"\n",r.createElement(t.p,null,"The Node renderer is a service that accepts JavaScript bundles uploaded by your Rails app and executes\nthem to render components. This is its purpose, not a flaw â but it has a direct consequence:"),"\n",r.createElement(t.blockquote,null,"\n",r.createElement(t.p,null,r.createElement(t.strong,null,"Anyone who can reach the renderer's port and authenticate can execute arbitrary JavaScript with the\nrenderer process's privileges")," (by uploading a bundle, or by sending a rendering request that the\nbundle evaluates). The renderer is ",r.createElement(t.strong,null,"not")," a sandbox for untrusted code and must be treated as an\ninternal service with the same trust level as your Rails app servers."),"\n"),"\n",r.createElement(t.p,null,"Concretely, the worker exposes these HTTP endpoints\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">packages/react-on-rails-pro-node-renderer/src/worker.ts</code>'}}),"):"),"\n",r.createElement(t.table,null,r.createElement(t.thead,null,r.createElement(t.tr,null,r.createElement(t.th,null,"Endpoint"),r.createElement(t.th,null,"Auth"),r.createElement(t.th,null,"Purpose"))),r.createElement(t.tbody,null,r.createElement(t.tr,null,r.createElement(t.td,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">POST /bundles/:bundleTimestamp/render/:renderRequestDigest</code>'}})),r.createElement(t.td,null,"password (via ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">performRequestPrechecks</code>'}}),")"),r.createElement(t.td,null,"Evaluate a rendering request;
1 may also carry bundle uploads")),r.createElement(t.tr,null,r.createElement(t.td,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">POST /bundles/:bundleTimestamp/incremental-render/:renderRequestDigest</code>'}})),r.createElement(t.td,null,"password (via ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">performRequestPrechecks</code>'}}),")"),r.createElement(t.td,null,"Streaming/incremental rendering")),r.createElement(t.tr,null,r.createElement(t.td,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">POST /upload-assets</code>'}})),r.createElement(t.td,null,"password (via ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">performRequestPrechecks</code>'}}),")"),r.createElement(t.td,null,"Upload server bundles and assets")),r.createElement(t.tr,null,r.createElement(t.td,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">POST /asset-exists</code>'}})),r.createElement(t.td,null,"password (via ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">authenticate</code>'}}),", no protocol-version check)"),r.createElement(t.td,null,"Check whether an uploaded asset exists")),r.createElement(t.tr,null,r.createElement(t.td,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">GET /info</code>'}})),r.createElement(t.td,null,r.createElement(t.strong,null,"none")),r.createElement(t.td,null,"Returns ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">node_version</code>'}})," and ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">renderer_version</code>'}}))))),"\n",r.createElement(t.p,null,"Note for reviewers: unlike the other authenticated endpoints, ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">/asset-exists</code>'}})," calls ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">authenticate</code>'}}),"\ndirectly rather than through ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">performRequestPrechecks</code>'}}),", so it skips the protocol-version check â a\ncompatibility control, not a security control."),"\n",r.createElement(t.p,null,"Known limitation: ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">GET /info</code>'}})," is unauthenticated and discloses the Node and renderer versions\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">worker.ts</code>'}}),", the ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:"<code class=\"language-text\">app.get('/info', ...)</code>"}})," route). This is harmless on a private network but is version\ndisclosure if you expose the port publicly â one more reason never to do so."),"\n",r.createElement(t.h3,{id:"network-exposure",style:{position:"relative"}},r.createElement(t.a,{href:"#network-exposure","aria-label":"network exposure permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Network exposure"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,"The renderer binds to ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">localhost</code>'}})," by default (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:"<code class=\"language-text\">host: env.RENDERER_HOST || 'localhost'</code>"}})," in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts</code>'}}),"). Setting ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RENDERER_HOST=0.0.0.0</code>'}}
1),"\nis needed for containerized deployments; if you do, network access control must come from your\ninfrastructure (security groups, network policies, private VPC subnets)."),"\n",r.createElement(t.li,null,"The renderer listens for ",r.createElement(t.strong,null,"cleartext HTTP/2 (h2c)")," by default, or cleartext HTTP/1.1 when\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">fastifyServerOptions.http2</code>'}})," is ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">false</code>'}}),", and does not terminate TLS itself (see the server construction in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">run()</code>'}})," in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">worker.ts</code>'}}),"). The shared password travels in the request body. Treat the\nRails-to-renderer link as plaintext: keep it on a private network, or place TLS-terminating\ninfrastructure in front of the renderer and use an ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">https://</code>'}})," value for ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">config.renderer_url</code>'}})," on the\nRails side. See ",r.createElement(t.a,{href:"../building-features/node-renderer/health-checks.md#choosing-h2c-or-http11"},"Choosing h2c or HTTP/1.1"),"."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Never expose the renderer port to the public internet."),' There is no rate limiting, no TLS, and the\nauthenticated surface is "execute JavaScript".'),"\n"),"\n",r.createElement(t.h3,{id:"authentication-between-rails-and-the-renderer",style:{position:"relative"}},r.createElement(t.a,{href:"#authentication-between-rails-and-the-renderer","aria-label":"authentication between rails and the renderer permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Authentication between Rails and the renderer"),"\n",r.createElement(t.p,null,"Authentication is a single shared secret:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,"The renderer checks the password from the request body\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">authenticate</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts</code>'}}),"). A\nsubmitted password whose byte length differs from the configured secret is rejected with ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">401</code>'}}),"\n",r.createElement(t.em,null,"before")," any comparison; only same-length candidates are then compared with\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">crypto.timingSafeEqual</code>'}}),". The comparison is therefore timing-safe for same-length guesses, but the\nearly length check means the secret's length is not protected â use a long random secret, not a\nshort passphrase. Comparison errors are also rejected with ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">401</code>'}}),"."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Production-like environments fail closed on both sides.")," If neither ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RAILS_ENV</code>'}})," nor ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">NODE_ENV</code>'}})," is a\ndevelopment/test value, the renderer refuses to start without a password\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">validatePasswordForProduction</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">configBuilder.ts</code>'}}),"), and the Rails side raises at configuration time\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">validate_renderer_password_for_production</code>'}})," in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails_pro/lib/react_on_rails_pro/configuration.rb</code>'}}),"). When both environments are unset, the\ncode treats the environment as production-like and still requires a password."),"\n",r.createElement(t.li,null,"Both sides warn at startup if the password matches a known-default value or is shorter than 16\ncharacters (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">KNOWN_WEAK_PASSWORDS</code>'}})," / ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">MIN_PASSWORD_LENGTH</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">configBuilder.ts</code>'}}),";\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">warn_if_renderer_password_weak</code>'}})," in the Pro gem's ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">configuration.rb</code>'}}),"). In these audited paths the\nliteral password value is not logged: the weak-
1password warnings report only length/known-default\nstatus, and the renderer masks the password in its config diagnostics. Other logging or\nerror-reporting paths in your app (e.g. exception trackers capturing config objects) are outside\nthis guarantee â audit them yourself."),"\n",r.createElement(t.li,null,"Rails resolves the password in this order: ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">config.renderer_password</code>'}}),", then a password embedded in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">config.renderer_url</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">https://:password@host:3800</code>'}}),"), then ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ENV["RENDERER_PASSWORD"]</code>'}}),"\n(documented in the error message in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">configuration.rb</code>'}}),")."),"\n"),"\n",r.createElement(t.p,null,"Known limitations of this model (by design â plan your network accordingly):"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,"One shared secret, not per-client credentials; there is no second factor and no built-in rotation\nmechanism. Rotate by deploying a new ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RENDERER_PASSWORD</code>'}})," to both sides."),"\n",r.createElement(t.li,null,"In ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">development</code>'}}),"/",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">test</code>'}})," environments the password is optional, and with no password set the renderer\naccepts unauthenticated requests (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">authenticate</code>'}})," returns success when no password is configured). Do not\nrun a password-less renderer outside an isolated development machine."),"\n",r.createElement(t.li,null,"Authentication grants access to all endpoints equally; there is no per-endpoint authorization."),"\n"),"\n",r.createElement(t.h3,{id:"code-execution-and-the-vm-context",style:{position:"relative"}},r.createElement(t.a,{href:"#code-execution-and-the-vm-context","aria-label":"code execution and the vm context permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Code execution and the VM context"),"\n",r.createElement(t.p,null,"The renderer evaluates bundles inside a Node ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">vm</code>'}})," context. Two configuration options control how much of\nthe host the bundle can reach, and their security semantics are documented in the config source\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts</code>'}}),", ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">Config</code>'}})," interface):"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">supportModules: true</code>'}})," injects a default set of Node globals ",r.createElement(t.strong,null,"and wraps the bundle so it receives the\nhost ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">require</code>'}})),", granting access to Node built-ins such as ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">fs</code>'}})," and ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">child_process</code>'}}),". This is required\nfor loadable-components and most real-world SSR bundles."),"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">additionalContext</code>'}}),": any plain-object value (even ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">{}</code>'}}),") also switches the bundle into CommonJS mode with\nthe host's unrestricted ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">require</code>'}}),"."),"\n",r.createElement(t.li,null,"The most restricted mode is ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">supportModules: false</code>'}})," ",r.createElement(t.strong,null,"and")," ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">additionalContext: null</code>'}}),"."),"\n"),"\n",r.createElement(t.p,null,"Even in the most restricted mode, do not treat the ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">
1vm</code>'}})," context as a security boundary: Node's ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">vm</code>'}})," module\nis explicitly ",r.createElement(t.a,{href:"https://nodejs.org/api/vm.html"},"not a security mechanism")," and the renderer makes no\nsandboxing claim beyond it. The security boundary is ",r.createElement(t.strong,null,"who can reach the port and authenticate"),", plus the\nOS-level privileges of the renderer process."),"\n",r.createElement(t.h3,{id:"hardening-checklist",style:{position:"relative"}},r.createElement(t.a,{href:"#hardening-checklist","aria-label":"hardening checklist permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Hardening checklist"),"\n",r.createElement(t.p,null,"Every item below maps to a configuration option or behavior verified in this repository:"),"\n",r.createElement(t.ol,null,"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Set a strong ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RENDERER_PASSWORD</code>'}}))," (random, ⥠16 characters) on both Rails and the renderer.\nProduction-like environments refuse to start without one; the length/known-default checks only warn."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Keep the renderer on a private network.")," Default bind is ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">localhost</code>'}}),"; if you set\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RENDERER_HOST=0.0.0.0</code>'}})," for containers, restrict ingress to your Rails app servers and your health\nchecker."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Treat the link as plaintext.")," The renderer speaks cleartext h2c by default or cleartext HTTP/1.1 when configured;\nuse a private network or external TLS termination, and prefer an ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">https://</code>'}})," ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">config.renderer_url</code>'}})," when a TLS hop\nexists. See ",r.createElement(t.a,{href:"../building-features/node-renderer/health-checks.md#choosing-h2c-or-http11"},"Choosing h2c or HTTP/1.1"),"."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Run the renderer as an unprivileged OS user / minimal container, with resource limits.")," The\nbundle typically runs with host ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">require</code>'}})," (see above), so the renderer process's OS privileges and\nresource ceiling are the effective blast radius. Set ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">--max-old-space-size</code>'}})," on the Node process and\nenforce container ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">memory</code>'}})," + ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">cpu</code>'}})," limits to bound the impact of a rogue or compromised bundle."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Don't expose ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">GET /info</code>'}}))," beyond your monitoring network; it is unauthenticated version disclosure."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Rotate the shared secret on a schedule")," by redeploying both sides with a new value; there is no\nbuilt-in rotation."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"Keep ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">NODE_ENV</code>'}}),"/",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">RAILS_ENV</code>'}})," set correctly.")," The fail-closed password requirement keys off these; an\nunset environment is treated as production-like (strict), but a mistakenly set ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">development</code>'}})," value\ndisables the requirement."),"\n"),"\n",r.createElement(t.h2,{id:"react-server-components-advisory-posture-pro",style:{position:"relative"}}
1,r.createElement(t.a,{href:"#react-server-components-advisory-posture-pro","aria-label":"react server components advisory posture pro permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"React Server Components advisory posture (Pro)"),"\n",r.createElement(t.h3,{id:"secure-the-rsc-payload-endpoint",style:{position:"relative"}},r.createElement(t.a,{href:"#secure-the-rsc-payload-endpoint","aria-label":"secure the rsc payload endpoint permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Secure the RSC payload endpoint"),"\n",r.createElement(t.p,null,"The endpoint is opt-in: React on Rails Pro does not mount it unless your routes call ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">rsc_payload_route</code>'}}),".\nThe RSC generator adds that route when you opt into RSC. Once mounted, treat it as a public API: the URL\ncontains a client-controlled component name and the ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">props</code>'}})," query parameter is also untrusted. Do not use\neither value as proof that a user may read a record, tenant, or internal component."),"\n",r.createElement(t.p,null,"Configure an authorizer to check the Rails session and restrict independently renderable components. The\ncallback runs before props JSON is parsed or rendering starts. Returning ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">false</code>'}})," or ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">nil</code>'}})," responds with\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">403 Forbidden</code>'}}),"; leaving the option unset preserves the existing allow-all behavior."),"\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<div class="gatsby-highlight" data-language="ruby"><pre class="language-ruby"><code class="language-ruby"><span class="token comment"># config/initializers/react_on_rails_pro.rb</span>\nallowed_rsc_components <span class="token operator">=</span> <span class="token string-literal"><span class="token string">%w[AccountPage DashboardPage]</span></span><span class="token punctuation">.</span>freeze\n\nReactOnRailsPro<span class="token punctuation">.</span>configure <span class="token keyword">do</span> <span class="token operator">|</span>config<span class="token operator">|</span>\n config<span class="token punctuation">.</span>rsc_payload_authorizer <span class="token operator">=</span> lambda <span class="token keyword">do</span> <span class="token operator">|</span>controller<span class="token punctuation">,</span> component_name<span class="token operator">|</span>\n controller<span class="token punctuation">.</span>session<span class="token punctuation">[</span><span class="token symbol">:user_id</span><span class="token punctuation">]</span><span class="token punctuation">.</span>present<span class="token operator">?</span> <span class="token operator">&&</span> allowed_rsc_components<span class="token punctuation">.</span><span class="token keyword">include</span><span class="token operator">?</span><span class="token punctuation">(</span>component_name<span class="token punctuation">)</span>\n <span class="token keyword">end</span>\n<span class="token keyword">end</span></code></pre></div>'}}),"\n",r.createElement(t.p,null,"For policy libraries or application-wide controller filters, route to an app-owned controller instead:"),"\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<div class="gatsby-highlight" data-language="ruby"><pre class="language-ruby"><code class="language-ruby"><span class="token comment"># app/controllers/rsc_payload_controller.rb</span>\n<span class="token keyword">class</span> <span class="token class-name">RscPayloadController</span> <span class="token operator"><</span> ApplicationController\n <span class="token keyword">include</span> ReactOnRailsPro<span class="token double-colon punctuation">::</span>RSCPayloadRenderer\n\n before_action <span class="token symbol">:authenticate_user!</span>\n<span class="token keyword">end</span>\n\n<span class="token comment"># config/routes.rb</span>\nrsc_payload_route controller<span class="token operator">:</span> <span class="token string-literal"><span class="token string">"rsc_payload"</span></span></code></pre></div>'}}),"\n",r.createElement(t.p,null,"The shipped default payload controller inherits from ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ActionController::Base</code>'}}),", not your\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">ApplicationController</code>'}}),", so your application's filters do not apply to it automatically. Whichever seam\nyou use, derive identity and permissions from the session and database. Never authorize from RSC props;\nthe browser can alter them before every payload request."),"\n",r.createElement(t.h3,{id:"track-upstream-rsc-advisories",style:{position:"relative"}}
1,r.createElement(t.a,{href:"#track-upstream-rsc-advisories","aria-label":"track upstream rsc advisories permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Track upstream RSC advisories"),"\n",r.createElement(t.p,null,"RSC support in React on Rails Pro is built directly on React's Flight packages: the\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-on-rails-rsc</code>'}})," package wraps React's ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-server-dom-webpack</code>'}}),". Vulnerabilities in those upstream\npackages therefore apply to this stack, and the project's response is enforced in code:"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"December 2025 React RSC advisories.")," The React team published critical advisories affecting React\nServer Components, including a remote code execution issue\n(",r.createElement(t.a,{href:"https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components"},"CVE-2025-55182"),")\nand follow-on\n",r.createElement(t.a,{href:"https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components"},"denial-of-service and source-code exposure fixes"),".\nReact on Rails Pro shipped corresponding dependency floors; see the ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">CHANGELOG.md</code>'}})," security entries for\nCVE-2025-55182 (",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails/pull/2175"},"PR 2175"),") and\nCVE-2025-55183/55184/67779 (",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails/pull/2233"},"PR 2233"),")."),"\n",r.createElement(t.li,null,r.createElement(t.strong,null,"The patched-version requirement is checked, not just documented.")," For React on Rails Pro 17 RSC, the\nsupported React range is ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">19.2.x</code>'}})," with patch ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">>= 19.2.7</code>'}})," (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">~19.2.7</code>'}}),") and a stable\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-on-rails-rsc</code>'}})," 19.2.x package with patch >= 19.2.1:","\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">rake react_on_rails:doctor</code>'}})," warns when the installed React is below the supported patch floor and reports the\nPro 17 RSC floor (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">check_rsc_react_version</code>'}})," in ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/react_on_rails/doctor.rb</code>'}}),")."),"\n",r.createElement(t.li,null,"The RSC generator emits the same floor/coordination warning at setup time\n(",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react_on_rails/lib/generators/react_on_rails/rsc_setup.rb</code>'}}),")."),"\n",r.createElement(t.li,null,"The Node renderer runs an RSC peer-compatibility check at startup (",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">runRscPeerCompatibilityCheck</code>'}}),",\ncalled from the renderer's master, worker, and wrapper entry points in\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">packages/react-on-rails-pro-node-renderer/src/</code>'}}),"). Incompatible ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react</code>'}}),", ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-dom</code>'}}),", or\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">react-on-rails-rsc</code>'}})," versions ",r.createElement(t.strong,null,"fail startup"),". Setting ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">REACT_ON_RAILS_PRO_DISABLE_VERSION_CHECK=1</code>'}}),"\ndowngrades the hard startup failure to a warning â ",r.createElement(t.strong,null,"do not set this in production: it allows the renderer to\nboot below the verified React/RSC floor.")),"\n"),"\n"),"\n"),"\n",r.createElement(t.h3,{id:"how-to-ver
1ify-your-own-status",style:{position:"relative"}},r.createElement(t.a,{href:"#how-to-verify-your-own-status","aria-label":"how to verify your own status permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"How to verify your own status"),"\n",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<div class="gatsby-highlight" data-language="bash"><pre class="language-bash"><code class="language-bash"><span class="token comment"># Reports your React/RSC versions and flags the security floor:</span>\nbundle <span class="token builtin class-name">exec</span> rake react_on_rails:doctor</code></pre></div>'}}),"\n",r.createElement(t.p,null,"Also subscribe to ",r.createElement(t.a,{href:"https://react.dev/blog"},"React's blog")," for upstream advisories, and watch this\nrepository's ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">CHANGELOG.md</code>'}})," security entries for the corresponding React on Rails releases."),"\n",r.createElement(t.h2,{id:"reporting-vulnerabilities",style:{position:"relative"}},r.createElement(t.a,{href:"#reporting-vulnerabilities","aria-label":"reporting vulnerabilities permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Reporting vulnerabilities"),"\n",r.createElement(t.p,null,"The supported-version policy, triage commitments, and private reporting process live in\n",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails/blob/main/SECURITY.md"},"SECURITY.md")," at the repository root.\nFormalizing the public advisory process (security contact alias, GitHub Security Advisories policy) is\ntracked in ",r.createElement(t.a,{href:"https://github.com/shakacode/react_on_rails/issues/3266"},"issue #3266"),"."),"\n",r.createElement(t.h2,{id:"related-documentation",style:{position:"relative"}},r.createElement(t.a,{href:"#related-documentation","aria-label":"related documentation permalink",className:"anchor before"},r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>'}})),"Related documentation"),"\n",r.createElement(t.ul,null,"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"/linked-assets/7351d0b7fb98f9692df4cdced5b4f441/review-app-security.md"},"Review App Security")," â running untrusted PR code in review apps"),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"/linked-assets/b6490192988c8a036f273ba296fd689e/basics.md"},"Node Renderer Basics")," â renderer architecture"),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"/linked-assets/3f622c8d69dadb55215173974f74c90e/js-configuration.md"},"Node Renderer JS Configuration")," â all renderer\nconfiguration options, including the ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">supportModules</code>'}})," / ",r.createElement(t.span,{dangerouslySetInnerHTML:{__html:'<code class="language-text">additionalContext</code>'}})," runtime-globals notes"),"\n",r.createElement(t.li,null,r.createElement(t.a,{href:"/linked-assets/1a4faa937a43404d4be451c7c345a576/docker-deployment.md"},"Docker Deployment")," â containerized deployment patterns"),"\n"))}var s=function(e){void 0===e&&(e={});const{wrapper:t}=Object.assign({},(0,a.R)(),e.components);return t?r.createElement(t,e,r.createElement(l,e)):l(e)},o=n(63679),c=n(95197),i=n(47388),d=n(23690),u=n(14487);const p=o.default.p.withConfig({displayName:"doc-article__RedirectFallback",componentId:"sc-tic2w5-0"})(["padding:2rem;
1"]),h=e=>{let{data:t,location:n,children:a}=e;const{headings:l}=t.article.childMdx,s=(0,c.X9)(n.pathname),[o,d]=r.useState(null),h=n.pathname.split("/"),m=h.indexOf("docs"),g=-1===m?n.pathname.length:h.slice(0,m+1).join("/").length,_=n.pathname.slice(0,g),E=n.pathname.slice(g+1).replace(/\/$/,"").split("/").filter((e=>""!==e)),y=E.length>1?E.slice(0,-1).join("/"):"";r.useEffect((()=>{if(!s)return;const{hash:e}=window.location;d(e);const t=""+s+e;window.location.href!==t&&window.location.replace(t)}),[s]);const S=null===s||null===o?null:""+s+o;return r.createElement(r.Fragment,null,null!==S?r.createElement(p,{role:"status","aria-live":"polite"},"Redirecting..."," ",r.createElement("a",{href:S,target:"_blank",rel:"noopener noreferrer"},"Click here if not redirected.")):r.createElement(i.A,{main:r.createElement(u.A,{body:a,headings:l,transformUrl:e=>{if(e.startsWith("http")||e.startsWith("#"))return e;const t=e.replace(/\.md$/,"/");if(t.startsWith("./"))return _+(""===y?"":"/"+y)+t.slice(1);if(t.startsWith("../")){if(""===y)return""+_+t.slice(2);const e=y.split("/").slice(0,-1).join("/");return _+(""===e?"":"/"+e)+t.slice(2)}return _+"/"+(""===y?"":y+"/")+t}})}))};function m(e){return r.createElement(h,e,r.createElement(s,e))}const g=e=>{let{data:t,location:n,pageContext:a}=e;const{headings:l}=t.article.childMdx,{siteUrl:s}=t.site.siteMetadata,{repoDisplayName:o}=a,i=l.find((e=>1===e.depth)),u=(0,c.X9)(n.pathname)||""+s.replace(/\/+$/,"")+n.pathname;return r.createElement(d.A,{title:i?i.value:o,description:i?i.value:o+" documentation",isArticle:!0,canonical:u,pathname:n.pathname})}}}]); 2//# sourceMappingURL=component---src-template-doc-article-tsx-content-file-path-cache-gatsby-source-git-react-on-rails-docs-oss-deployment-security-model-and-hardening-md-ebbaf2324afea6c4f7b8.js.map
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.