From e283277f41c32dff417c8352b89d02bb0cfcdcbf Mon Sep 17 00:00:00 2001 From: Alexey Romanov Date: Sat, 11 Jul 2026 17:38:07 +0300 Subject: [PATCH 1/5] Use raw bodies to avoid render request escaping --- .../package.json | 1 - .../src/ReactOnRailsProNodeRenderer.ts | 1 - .../src/worker.ts | 92 +++++++++++++++++-- .../tests/worker.test.ts | 42 +++++++++ pnpm-lock.yaml | 11 --- react_on_rails_pro/Gemfile.lock | 2 +- .../renderer_http_client.rb | 28 ++---- .../lib/react_on_rails_pro/request.rb | 44 ++++++++- .../renderer_http_client_spec.rb | 47 ++++++---- .../spec/react_on_rails_pro/request_spec.rb | 87 +++++++++++++++++- 10 files changed, 291 insertions(+), 64 deletions(-) diff --git a/packages/react-on-rails-pro-node-renderer/package.json b/packages/react-on-rails-pro-node-renderer/package.json index d708fb6628..8734081847 100644 --- a/packages/react-on-rails-pro-node-renderer/package.json +++ b/packages/react-on-rails-pro-node-renderer/package.json @@ -35,7 +35,6 @@ "lib/**/*.d.ts" ], "dependencies": { - "@fastify/formbody": "^7.4.0 || ^8.0.2", "@fastify/multipart": "^8.3.1 || ^9.0.3", "fastify": "^5.8.5", "fs-extra": "^11.2.0", diff --git a/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts b/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts index 1f7c42871a..10b58a38cd 100644 --- a/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts +++ b/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts @@ -46,7 +46,6 @@ export async function reactOnRailsProNodeRenderer(config: Partial = {}) `Node.js version ${process.versions.node} is not supported by Fastify ${fastifyVersion}. Please either use Node.js v20 or higher or downgrade Fastify by setting the following resolutions in your package.json: { - "@fastify/formbody": "^7.4.0", "@fastify/multipart": "^8.3.1", "fastify": "^4.29.0", }`, diff --git a/packages/react-on-rails-pro-node-renderer/src/worker.ts b/packages/react-on-rails-pro-node-renderer/src/worker.ts index 05a56cb9c7..f551de82ce 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -24,7 +24,6 @@ import { randomUUID } from 'crypto'; import { rm } from 'fs/promises'; import { Transform } from 'stream'; import fastify from 'fastify'; -import fastifyFormbody from '@fastify/formbody'; import fastifyMultipart, { type MultipartFile } from '@fastify/multipart'; import log, { sharedLoggerOptions } from './shared/log.js'; import packageJson from './shared/packageJson.js'; @@ -399,6 +398,8 @@ export const disableHttp2 = () => { type WithBodyArrayField = T & { [P in K | `${K}[]`]?: string | string[] }; const INVALID_CONTENT_LENGTH_ERROR_CODE = 'FST_ERR_CTP_INVALID_CONTENT_LENGTH'; +const RAW_RENDER_CONTENT_TYPE = 'application/vnd.react-on-rails.render-request+javascript'; +const RAW_RENDER_HEADER_PREFIX = 'x-react-on-rails-pro-'; const errorCode = (error: unknown): string | undefined => { const code = (error as { code?: unknown })?.code; @@ -459,6 +460,64 @@ const extractBodyArrayField = ( return undefined; }; +type RawRenderHeaders = Record; + +const scalarHeader = (headers: RawRenderHeaders, name: string) => { + const value = headers[name]; + return typeof value === 'string' ? value : undefined; +}; + +const normalizeRawRenderRequest = ( + renderingRequest: unknown, + headers: RawRenderHeaders, +): { body?: Record; error?: string } => { + if (typeof renderingRequest !== 'string') { + return { error: 'Invalid raw render request body: expected a JavaScript string.' }; + } + + const authorization = scalarHeader(headers, 'authorization'); + if (authorization && !authorization.startsWith('Bearer ')) { + return { error: 'Invalid Authorization header for raw render request.' }; + } + + const dependencyHeader = scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps`); + let dependencyBundleTimestamps: unknown = []; + if (dependencyHeader !== undefined) { + try { + dependencyBundleTimestamps = JSON.parse(dependencyHeader); + } catch { + return { error: 'Invalid dependency bundle timestamps header: expected a JSON array.' }; + } + if ( + !Array.isArray(dependencyBundleTimestamps) || + !dependencyBundleTimestamps.every((timestamp) => typeof timestamp === 'string') + ) { + return { error: 'Invalid dependency bundle timestamps header: expected an array of strings.' }; + } + } + + const observabilityHeader = scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rsc-stream-observability`); + if ( + observabilityHeader !== undefined && + observabilityHeader !== 'true' && + observabilityHeader !== 'false' + ) { + return { error: 'Invalid RSC stream observability header: expected true or false.' }; + } + + return { + body: { + renderingRequest, + protocolVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`), + gemVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`), + railsEnv: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`), + password: authorization?.slice('Bearer '.length), + dependencyBundleTimestamps, + ...(observabilityHeader === undefined ? {} : { rscStreamObservability: observabilityHeader }), + }, + }; +}; + function discardMultipartFile(part: MultipartFile) { part.file.resume(); // eslint-disable-next-line no-param-reassign @@ -534,8 +593,6 @@ export default function run(config: Partial) { } }); - // Supports application/x-www-form-urlencoded - void app.register(fastifyFormbody); // Supports multipart/form-data void app.register(fastifyMultipart, { attachFieldsToBody: 'keyValues', @@ -584,6 +641,10 @@ export default function run(config: Partial) { }, }); + app.addContentTypeParser(RAW_RENDER_CONTENT_TYPE, { parseAs: 'string' }, (_req, body, done) => { + done(null, body); + }); + // Ensure NDJSON bodies are not buffered and are available as a stream immediately app.addContentTypeParser('application/x-ndjson', (req, payload, done) => { // Pass through the raw stream; the route will consume req.raw @@ -598,7 +659,25 @@ export default function run(config: Partial) { // Can't infer from the route like Express can Params: { bundleTimestamp: string; renderRequestDigest: string }; }>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => { - const precheckResult = performRequestPrechecks(req.body); + let body: Record; + if (req.headers['content-type']?.split(';', 1)[0] === RAW_RENDER_CONTENT_TYPE) { + const normalizedRequest = normalizeRawRenderRequest(req.body, req.headers); + if (normalizedRequest.error || !normalizedRequest.body) { + await setResponse( + badRequestResponseResult(normalizedRequest.error ?? 'Invalid raw render request.'), + res, + ); + return; + } + body = normalizedRequest.body; + } else if (req.body && typeof req.body === 'object') { + body = req.body; + } else { + await setResponse(badRequestResponseResult('Invalid or missing request body.'), res); + return; + } + + const precheckResult = performRequestPrechecks(body); if (precheckResult) { await setResponse(precheckResult, res); return; @@ -615,11 +694,6 @@ export default function run(config: Partial) { // await delay(100000); // } - const { body } = req; - if (!body || typeof body !== 'object') { - await setResponse(badRequestResponseResult('Invalid or missing request body.'), res); - return; - } const { renderingRequest } = body; if (!isValidRenderingRequest(renderingRequest)) { await setResponse(badRequestResponseResult(invalidRenderingRequestMessage(body)), res); diff --git a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts index f7aabe98d6..d6a0d79093 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts @@ -214,6 +214,48 @@ describe('worker', () => { expect(fs.existsSync(assetPathOther(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true); }); + test('POST raw render request normalizes headers and executes the JavaScript body verbatim', async () => { + await createVmBundleForTest(); + const app = createWorker({ password: 'my_password' }); + const renderingRequest = "ReactOnRails.dummy // quotes: '\"' and unicode: ✓"; + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ + 'content-type': 'application/vnd.react-on-rails.render-request+javascript', + authorization: 'Bearer my_password', + 'x-react-on-rails-pro-protocol-version': protocolVersion, + 'x-react-on-rails-pro-gem-version': gemVersion, + 'x-react-on-rails-pro-rails-env': railsEnv, + 'x-react-on-rails-pro-dependency-bundle-timestamps': JSON.stringify([String(BUNDLE_TIMESTAMP)]), + 'x-react-on-rails-pro-rsc-stream-observability': 'false', + }) + .payload(renderingRequest) + .end(); + + expect(res.statusCode).toBe(200); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + + test('POST raw render request rejects malformed dependency metadata', async () => { + const app = createWorker(); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ + 'content-type': 'application/vnd.react-on-rails.render-request+javascript', + 'x-react-on-rails-pro-protocol-version': protocolVersion, + 'x-react-on-rails-pro-dependency-bundle-timestamps': 'not-json', + }) + .payload('ReactOnRails.dummy') + .end(); + + expect(res.statusCode).toBe(400); + expect(res.payload).toContain('expected a JSON array'); + }); + test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is missing', async () => { const app = worker({ serverBundleCachePath: serverBundleCachePathForTest(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de9cde98f6..9d1752436d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,9 +291,6 @@ importers: packages/react-on-rails-pro-node-renderer: dependencies: - '@fastify/formbody': - specifier: ^7.4.0 || ^8.0.2 - version: 8.0.2 '@fastify/multipart': specifier: ^8.3.1 || ^9.0.3 version: 9.3.0 @@ -2012,9 +2009,6 @@ packages: '@fastify/fast-json-stringify-compiler@5.0.3': resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - '@fastify/formbody@8.0.2': - resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} - '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} @@ -11649,11 +11643,6 @@ snapshots: dependencies: fast-json-stringify: 6.3.0 - '@fastify/formbody@8.0.2': - dependencies: - fast-querystring: 1.1.2 - fastify-plugin: 5.1.0 - '@fastify/forwarded@3.0.1': {} '@fastify/merge-json-schemas@0.2.1': diff --git a/react_on_rails_pro/Gemfile.lock b/react_on_rails_pro/Gemfile.lock index 9db8a9c228..5c64a98a65 100644 --- a/react_on_rails_pro/Gemfile.lock +++ b/react_on_rails_pro/Gemfile.lock @@ -512,4 +512,4 @@ DEPENDENCIES yard BUNDLED WITH - 2.5.4 + 4.0.10 diff --git a/react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb b/react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb index 98acfdf825..d0f7e9efaf 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb @@ -487,26 +487,11 @@ def build_multipart_body(form, boundary: SecureRandom.hex(24)) end def build_form_body(form) - return build_multipart_body(form) if form.any? { |_name, value| file_part?(value) } - - [ - [["content-type", "application/x-www-form-urlencoded"]], - URI.encode_www_form(flatten_url_encoded_form(form)) - ] + build_multipart_body(form) end private - def flatten_url_encoded_form(form) - form.each_with_object([]) do |(name, value), pairs| - if value.is_a?(Array) - value.each { |item| pairs << ["#{name}[]", item] } - else - pairs << [name, value] - end - end - end - def append_multipart_value(body, boundary, name, value) if value.is_a?(Array) value.each { |item| append_scalar_part(body, boundary, "#{name}[]", item) } @@ -572,9 +557,9 @@ def initialize(origin:, pool_size:, connect_timeout:, read_timeout:, force_http2 @closed_mutex = Mutex.new end - def post(path, form: nil, json: nil, stream: false) + def post(path, form: nil, json: nil, raw: nil, stream: false) ensure_open! - headers, body = request_body(form:, json:) + headers, body = request_body(form:, json:, raw:) build_response(stream:) do |yielder, status_assigner, headers_assigner| execute_request(:post, path, [headers, body], stream:, @@ -630,11 +615,16 @@ def close private - def request_body(form:, json:) + def request_body(form:, json:, raw:) + body_options = [form, json, raw].compact + raise ArgumentError, "Specify only one request body encoding" if body_options.length > 1 + if form self.class.build_form_body(form) elsif json [[["content-type", "application/json"]], JSON.generate(json)] + elsif raw + [raw.fetch(:headers), raw.fetch(:body)] else [[], nil] end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index bc2975b536..c61dde55bc 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -22,6 +22,9 @@ module ReactOnRailsPro class Request # rubocop:disable Metrics/ClassLength + RAW_RENDER_CONTENT_TYPE = "application/vnd.react-on-rails.render-request+javascript" + RAW_RENDER_HEADER_PREFIX = "x-react-on-rails-pro-" + class UploadAssetsWaiter def initialize if Fiber.scheduler @@ -89,7 +92,7 @@ def reset_connection def render_code(path, js_code, send_bundle) Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" } form = form_with_code(js_code, send_bundle) - perform_request(path, form:) + perform_render_request(path, js_code, form:, send_bundle:) end def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observability: false) @@ -109,7 +112,7 @@ def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observabili end form = form_with_code(js_code, false, rsc_stream_observability:) - perform_request(path, form:, stream: true) + perform_render_request(path, js_code, form:, send_bundle: false, stream: true) end end @@ -339,6 +342,43 @@ def form_with_code(js_code, send_bundle, rsc_stream_observability: false) form end + def perform_render_request(path, js_code, form:, send_bundle:, stream: false) + if send_bundle + perform_request(path, form:, stream:) + else + perform_request(path, raw: raw_render_request(js_code, form), stream:) + end + end + + def raw_render_request(js_code, form) + metadata = form.except("renderingRequest") + headers = [ + ["content-type", RAW_RENDER_CONTENT_TYPE], + ["#{RAW_RENDER_HEADER_PREFIX}protocol-version", metadata.delete("protocolVersion").to_s], + ["#{RAW_RENDER_HEADER_PREFIX}gem-version", metadata.delete("gemVersion").to_s], + ["#{RAW_RENDER_HEADER_PREFIX}rails-env", metadata.delete("railsEnv").to_s], + [ + "#{RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps", + JSON.generate(Array(metadata.delete("dependencyBundleTimestamps"))) + ] + ] + password = metadata.delete("password") + headers << ["authorization", "Bearer #{sanitize_raw_render_header(password)}"] if password + if metadata.key?("rscStreamObservability") + observability = metadata.delete("rscStreamObservability").to_s + headers << ["#{RAW_RENDER_HEADER_PREFIX}rsc-stream-observability", observability] + end + raise ArgumentError, "Unsupported raw render metadata: #{metadata.keys.join(', ')}" unless metadata.empty? + + { headers:, body: js_code } + end + + def sanitize_raw_render_header(value) + value.to_s.tap do |header_value| + raise ArgumentError, "Renderer metadata headers cannot contain newlines" if header_value.match?(/[\r\n]/) + end + end + def populate_form_with_bundle_and_assets(form, check_bundle:, assets_to_copy: assets_to_copy_for_upload) pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool diff --git a/react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb index 808bda22f8..8c890a87bf 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb @@ -300,22 +300,6 @@ def close; end end describe ".build_form_body" do - it "uses url-encoded form data when no uploaded files are present" do - headers, body = described_class.build_form_body( - { - "password" => "secret", - "targetBundles" => %w[server rsc], - "renderingRequest" => "console.log('Hello, world!');" - } - ) - - expect(headers).to eq([["content-type", "application/x-www-form-urlencoded"]]) - expect(body).to include("password=secret") - expect(body).to include("targetBundles%5B%5D=server") - expect(body).to include("targetBundles%5B%5D=rsc") - expect(body).to include("renderingRequest=console.log") - end - it "uses multipart form data when uploaded files are present" do Tempfile.create(["react-on-rails-pro", ".js"]) do |file| form = { @@ -337,6 +321,37 @@ def close; end end end + describe "raw request bodies" do + it "passes custom headers and the body through without encoding" do + client = described_class.new( + origin: "http://localhost:3800", + pool_size: 1, + connect_timeout: 1, + read_timeout: 1 + ) + raw = { headers: [["content-type", "application/example"]], body: "const value = '✓';" } + + expect(client.send(:request_body, form: nil, json: nil, raw:)).to eq([raw[:headers], raw[:body]]) + ensure + client&.close + end + + it "rejects ambiguous body encodings" do + client = described_class.new( + origin: "http://localhost:3800", + pool_size: 1, + connect_timeout: 1, + read_timeout: 1 + ) + + expect do + client.send(:request_body, form: { "key" => "value" }, json: nil, raw: { headers: [], body: "raw" }) + end.to raise_error(ArgumentError, /only one request body encoding/) + ensure + client&.close + end + end + describe ".get" do it "does not force HTTP/2 for arbitrary dev-server asset URLs" do response = described_class::Response.new do |yielder, status_assigner| diff --git a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb index d18993c4cc..d9d7a443bb 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb @@ -161,9 +161,9 @@ def mock_response(status:, chunks: []) end it "passes the stream observability opt-in to the renderer request" do - captured_form = nil - allow(mock_connection).to receive(:post) do |_path, form:, **_opts| - captured_form = form + captured_raw_request = nil + allow(mock_connection).to receive(:post) do |_path, raw:, **_opts| + captured_raw_request = raw mock_response(status: 200, chunks: [to_length_prefixed("Hello, world!")]) end @@ -175,7 +175,8 @@ def mock_response(status:, chunks: []) ) stream.each_chunk(&:itself) - expect(captured_form["rscStreamObservability"]).to be true + expect(captured_raw_request[:headers]) + .to include(%w[x-react-on-rails-pro-rsc-stream-observability true]) end it "raises duplicate bundle upload error when server asks for bundle twice" do @@ -238,6 +239,84 @@ def mock_response(status:, chunks: []) end end + describe ".raw_render_request" do + it "puts JavaScript in the body and normalized metadata in headers" do + raw_request = described_class.send( + :raw_render_request, + "console.log('héllo');", + { + "renderingRequest" => "console.log('héllo');", + "protocolVersion" => "2.0.0", + "gemVersion" => "16.0.0", + "password" => "secret", + "railsEnv" => "test", + "dependencyBundleTimestamps" => %w[server rsc], + "rscStreamObservability" => true + } + ) + + expect(raw_request[:body]).to eq("console.log('héllo');") + expect(raw_request[:headers]).to include( + ["content-type", "application/vnd.react-on-rails.render-request+javascript"], + ["authorization", "Bearer secret"], + ["x-react-on-rails-pro-protocol-version", "2.0.0"], + ["x-react-on-rails-pro-dependency-bundle-timestamps", '["server","rsc"]'], + %w[x-react-on-rails-pro-rsc-stream-observability true] + ) + end + + it "rejects header injection through the renderer password" do + expect do + described_class.send( + :raw_render_request, + "ReactOnRails.dummy", + { + "renderingRequest" => "ReactOnRails.dummy", + "protocolVersion" => "2.0.0", + "gemVersion" => "16.0.0", + "password" => "secret\r\ninjected: true", + "railsEnv" => "test", + "dependencyBundleTimestamps" => [] + } + ) + end.to raise_error(ArgumentError, /cannot contain newlines/) + end + end + + describe ".render_code" do + let(:form) do + { + "renderingRequest" => "ReactOnRails.dummy", + "protocolVersion" => "2.0.0", + "gemVersion" => "17.0.0", + "password" => "secret", + "railsEnv" => "test", + "dependencyBundleTimestamps" => [] + } + end + + before do + allow(described_class).to receive(:form_with_code).and_return(form) + allow(mock_connection).to receive(:post).and_return(mock_response(status: 200)) + end + + it "uses the raw body encoding when no bundle is attached" do + described_class.render_code(render_path, "ReactOnRails.dummy", false) + + expect(mock_connection).to have_received(:post).with( + render_path, + raw: hash_including(body: "ReactOnRails.dummy"), + stream: false + ) + end + + it "preserves the form path for bundle uploads" do + described_class.render_code(render_path, "ReactOnRails.dummy", true) + + expect(mock_connection).to have_received(:post).with(render_path, form:, stream: false) + end + end + describe ".upload_assets" do before do described_class.instance_variable_set(:@upload_assets_in_progress, nil) From ce32f4b022906f05fdec6b61ca28b684be784a49 Mon Sep 17 00:00:00 2001 From: Alexey Romanov Date: Sat, 11 Jul 2026 18:12:19 +0300 Subject: [PATCH 2/5] Keep renderer benchmarks on the active wire protocol --- benchmarks/bench-node-renderer.rb | 23 ++++++++---------- benchmarks/spec/bench_node_renderer_spec.rb | 26 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/benchmarks/bench-node-renderer.rb b/benchmarks/bench-node-renderer.rb index bf79278d6f..ae325e03fc 100755 --- a/benchmarks/bench-node-renderer.rb +++ b/benchmarks/bench-node-renderer.rb @@ -41,6 +41,8 @@ def read_password_from_config BASE_URL = env_or_default("BASE_URL", "localhost:3800") PROTOCOL_VERSION = read_protocol_version LOAD_GENERATOR_SHARDS = env_or_default("LOAD_GENERATOR_SHARDS", 1).to_i +RAW_RENDER_CONTENT_TYPE = "application/vnd.react-on-rails.render-request+javascript" +RAW_RENDER_PROTOCOL_HEADER = "X-React-On-Rails-Pro-Protocol-Version" # Test cases: JavaScript expressions to evaluate # Format: { name: "test_name", request: "javascript_code", rsc: true/false } @@ -105,8 +107,10 @@ def rsc_bundle?(bundle_timestamp) # Use curl with h2c since Net::HTTP doesn't support HTTP/2 result, status = Open3.capture2( "curl", "-s", "--http2-prior-knowledge", "-X", "POST", - "-H", "Content-Type: application/x-www-form-urlencoded", - "-d", body, + "-H", "Content-Type: #{RAW_RENDER_CONTENT_TYPE}", + "-H", "#{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION}", + "-H", "Authorization: Bearer #{PASSWORD}", + "--data-binary", body, url ) return nil unless status.success? @@ -145,11 +149,6 @@ def categorize_bundles(bundles) [rsc_bundle, non_rsc_bundle] end -# URL-encode special characters for form body -def url_encode(str) - URI.encode_www_form_component(str) -end - # Build render URL for a bundle and render name def render_url(bundle_timestamp, render_name) "http://#{BASE_URL}/bundles/#{bundle_timestamp}/render/#{render_name}" @@ -157,11 +156,7 @@ def render_url(bundle_timestamp, render_name) # Build request body for a rendering request def render_body(rendering_request) - [ - "protocolVersion=#{url_encode(PROTOCOL_VERSION)}", - "password=#{url_encode(PASSWORD)}", - "renderingRequest=#{url_encode(rendering_request)}" - ].join("&") + rendering_request end def validate_node_renderer_benchmark_config! @@ -380,7 +375,9 @@ def run_vegeta_benchmark(test_case, bundle_timestamp, shard_count: LOAD_GENERATO # Write targets file (Vegeta format with @body reference) File.write(targets_file, <<~TARGETS) POST #{target_url} - Content-Type: application/x-www-form-urlencoded + Content-Type: #{RAW_RENDER_CONTENT_TYPE} + #{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION} + Authorization: Bearer #{PASSWORD} @#{body_file} TARGETS diff --git a/benchmarks/spec/bench_node_renderer_spec.rb b/benchmarks/spec/bench_node_renderer_spec.rb index 06e1cfaffc..ee0757bdd1 100644 --- a/benchmarks/spec/bench_node_renderer_spec.rb +++ b/benchmarks/spec/bench_node_renderer_spec.rb @@ -141,6 +141,32 @@ def add(name:, rps:, p50:, status:, p90: nil) end describe "#run_vegeta_benchmark" do + it "sends the rendering request verbatim using the raw renderer protocol" do + stub_const("CONNECTIONS", 1) + stub_const("MAX_CONNECTIONS", 1) + stub_const("RATE", "max") + stub_const("DURATION", "1s") + + writes = {} + allow(File).to receive(:write) { |path, contents| writes[path] = contents } + allow(FileUtils).to receive(:rm_f) + allow(Process).to receive_messages(spawn: 1, wait2: [1, process_status(success: true)]) + allow(self).to receive_messages(system: true, parse_json_file: { "throughput" => 1, + "latencies" => { "50th" => 1_000_000, + "90th" => 2_000_000 }, + "status_codes" => { "200" => 1 } }) + + rendering_request = 'render({quoted: "value & more"})' + run_vegeta_benchmark({ name: "raw_request", request: rendering_request }, "bundleX", shard_count: 1) + + expect(writes.fetch("#{OUTDIR}/raw_request_vegeta_body.txt")).to eq(rendering_request) + expect(writes.fetch("#{OUTDIR}/raw_request_vegeta_targets.txt")).to include( + "Content-Type: #{RAW_RENDER_CONTENT_TYPE}", + "#{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION}", + "Authorization: Bearer #{PASSWORD}" + ) + end + it "runs all shards concurrently before merging their result streams into one Vegeta report" do stub_const("CONNECTIONS", 10) stub_const("MAX_CONNECTIONS", 10) From b62f91be56a0dda5ec96051661f18feb0016010a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 11 Jul 2026 23:24:11 -1000 Subject: [PATCH 3/5] Address review: auth-first prechecks, uniform header sanitization, formbody compat - Run protocol/password prechecks before raw header shape validation so a malformed but unauthenticated raw request fails auth (401) first, matching the parsed-body path and /asset-exists - Keep @fastify/formbody registered (with test) so a not-yet-upgraded gem in a rolling deploy still reaches the protocol/render path instead of an unactionable 415; package.json and pnpm-lock.yaml now match main - Sanitize every raw render metadata header value against CRLF injection, not just password, and document the three-way field-list sync requirement - Send every non-stream string response with explicit text/plain + nosniff, clearing the CodeQL js/reflected-xss alert on the render response path - Revert the incidental Gemfile.lock BUNDLED WITH bump (2.5.4 stays) - Add the [Pro] CHANGELOG entry with the renderer-first deploy-order note Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++ .../package.json | 1 + .../src/ReactOnRailsProNodeRenderer.ts | 1 + .../src/worker.ts | 71 ++++++++++--------- .../tests/worker.test.ts | 42 +++++++++++ pnpm-lock.yaml | 11 +++ react_on_rails_pro/Gemfile.lock | 2 +- .../lib/react_on_rails_pro/request.rb | 15 ++-- .../spec/react_on_rails_pro/request_spec.rb | 17 +++++ 9 files changed, 133 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b0d86d82..cb0ffd5ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,19 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ mismatches on streamed RSC apps such as the flagship demo. Fixes [Issue 4525](https://github.com/shakacode/react_on_rails/issues/4525). [PR 4532](https://github.com/shakacode/react_on_rails/pull/4532) by [justin808](https://github.com/justin808). +#### Changed + +- **[Pro] Render requests now send raw JavaScript bodies to the Node renderer**: Non-bundle render + requests use a raw `application/vnd.react-on-rails.render-request+javascript` body with metadata in + `X-React-On-Rails-Pro-*` headers instead of `application/x-www-form-urlencoded`, removing + URL-encoding overhead on large rendering payloads. The renderer still accepts the legacy form + encoding, so a not-yet-upgraded gem keeps working against an upgraded renderer during rolling + deploys; deploy the Node renderer before or together with the gem upgrade, since an older renderer + rejects the new content type. Fixes + [Issue 3584](https://github.com/shakacode/react_on_rails/issues/3584). + [PR 4579](https://github.com/shakacode/react_on_rails/pull/4579) by + [alexeyr-ci2](https://github.com/alexeyr-ci2). + #### Added - **[Pro] Configurable license-token secret sources**: Rails applications can now provide a paid diff --git a/packages/react-on-rails-pro-node-renderer/package.json b/packages/react-on-rails-pro-node-renderer/package.json index 8734081847..d708fb6628 100644 --- a/packages/react-on-rails-pro-node-renderer/package.json +++ b/packages/react-on-rails-pro-node-renderer/package.json @@ -35,6 +35,7 @@ "lib/**/*.d.ts" ], "dependencies": { + "@fastify/formbody": "^7.4.0 || ^8.0.2", "@fastify/multipart": "^8.3.1 || ^9.0.3", "fastify": "^5.8.5", "fs-extra": "^11.2.0", diff --git a/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts b/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts index 10b58a38cd..1f7c42871a 100644 --- a/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts +++ b/packages/react-on-rails-pro-node-renderer/src/ReactOnRailsProNodeRenderer.ts @@ -46,6 +46,7 @@ export async function reactOnRailsProNodeRenderer(config: Partial = {}) `Node.js version ${process.versions.node} is not supported by Fastify ${fastifyVersion}. Please either use Node.js v20 or higher or downgrade Fastify by setting the following resolutions in your package.json: { + "@fastify/formbody": "^7.4.0", "@fastify/multipart": "^8.3.1", "fastify": "^4.29.0", }`, diff --git a/packages/react-on-rails-pro-node-renderer/src/worker.ts b/packages/react-on-rails-pro-node-renderer/src/worker.ts index f551de82ce..38be7a54a4 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -24,6 +24,7 @@ import { randomUUID } from 'crypto'; import { rm } from 'fs/promises'; import { Transform } from 'stream'; import fastify from 'fastify'; +import fastifyFormbody from '@fastify/formbody'; import fastifyMultipart, { type MultipartFile } from '@fastify/multipart'; import log, { sharedLoggerOptions } from './shared/log.js'; import packageJson from './shared/packageJson.js'; @@ -107,45 +108,27 @@ function setHeaders(headers: ResponseResult['headers'], res: FastifyReply) { Object.entries(headers).forEach(([key, header]) => res.header(key, header)); } -function hasHeader(headers: ResponseResult['headers'], headerName: string) { - const lowerHeaderName = headerName.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === lowerHeaderName); -} - -function setStringResponseHeaders(headers: ResponseResult['headers'], res: FastifyReply) { - if (!hasHeader(headers, 'Content-Type')) { - res.type('text/plain; charset=utf-8'); - } - if (!hasHeader(headers, 'X-Content-Type-Options')) { - res.header('X-Content-Type-Options', 'nosniff'); - } -} - const setResponse = async (result: ResponseResult, res: FastifyReply) => { const { status, data, headers, stream } = result; if (status !== 200 && status !== 410) { log.info({ msg: 'Sending non-200, non-410 data back', data }); } - if (!stream && typeof data === 'string' && status >= 400) { - setHeaders(headers, res); - res.header('Content-Type', 'text/plain; charset=utf-8'); - res.header('X-Content-Type-Options', 'nosniff'); - res.status(status); - res.send(data); - return; - } - - if (!stream && typeof data === 'string') { - setStringResponseHeaders(headers, res); - } setHeaders(headers, res); res.status(status); if (stream) { await res.send(stream); - } else { - res.send(data); + return; + } + + if (typeof data === 'string') { + // String payloads (rendered output and error text) may embed request-derived + // content; the Rails client reads them as raw text, so an explicit non-HTML + // content type keeps reflected markup inert if a browser hits this endpoint. + res.header('Content-Type', 'text/plain; charset=utf-8'); + res.header('X-Content-Type-Options', 'nosniff'); } + res.send(data); }; function runWhenStreamFinishes( @@ -518,6 +501,17 @@ const normalizeRawRenderRequest = ( }; }; +// Extracts only the precheck fields, without shape validation, so protocol and password +// checks run before normalizeRawRenderRequest's 400s; a malformed but unauthenticated +// raw request must fail auth first, matching the parsed-body path and /asset-exists. +const rawRenderPrecheckBody = (headers: RawRenderHeaders) => { + const authorization = scalarHeader(headers, 'authorization'); + return { + protocolVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`), + password: authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization, + }; +}; + function discardMultipartFile(part: MultipartFile) { part.file.resume(); // eslint-disable-next-line no-param-reassign @@ -593,6 +587,11 @@ export default function run(config: Partial) { } }); + // Supports application/x-www-form-urlencoded. The gem no longer sends it (render + // requests use RAW_RENDER_CONTENT_TYPE), but keep parsing it so a not-yet-upgraded + // gem in a rolling deploy still reaches the protocol/render path instead of failing + // with an unactionable 415 at the content-type layer. + void app.register(fastifyFormbody); // Supports multipart/form-data void app.register(fastifyMultipart, { attachFieldsToBody: 'keyValues', @@ -661,6 +660,11 @@ export default function run(config: Partial) { }>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => { let body: Record; if (req.headers['content-type']?.split(';', 1)[0] === RAW_RENDER_CONTENT_TYPE) { + const precheckResult = performRequestPrechecks(rawRenderPrecheckBody(req.headers)); + if (precheckResult) { + await setResponse(precheckResult, res); + return; + } const normalizedRequest = normalizeRawRenderRequest(req.body, req.headers); if (normalizedRequest.error || !normalizedRequest.body) { await setResponse( @@ -672,17 +676,16 @@ export default function run(config: Partial) { body = normalizedRequest.body; } else if (req.body && typeof req.body === 'object') { body = req.body; + const precheckResult = performRequestPrechecks(body); + if (precheckResult) { + await setResponse(precheckResult, res); + return; + } } else { await setResponse(badRequestResponseResult('Invalid or missing request body.'), res); return; } - const precheckResult = performRequestPrechecks(body); - if (precheckResult) { - await setResponse(precheckResult, res); - return; - } - // DO NOT REMOVE (REQUIRED FOR TIMEOUT TESTING) // if(TESTING_TIMEOUTS && getRandomInt(2) === 1) { // console.log( diff --git a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts index d6a0d79093..1028603c7f 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts @@ -256,6 +256,48 @@ describe('worker', () => { expect(res.payload).toContain('expected a JSON array'); }); + test('POST raw render request authenticates before validating header shape', async () => { + const app = createWorker({ password: 'my_password' }); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ + 'content-type': 'application/vnd.react-on-rails.render-request+javascript', + authorization: 'Bearer wrong_password', + 'x-react-on-rails-pro-protocol-version': protocolVersion, + 'x-react-on-rails-pro-dependency-bundle-timestamps': 'not-json', + }) + .payload('ReactOnRails.dummy') + .end(); + + expect(res.statusCode).toBe(401); + expect(res.payload).toBe('Wrong password'); + }); + + test('POST render request still accepts application/x-www-form-urlencoded bodies from older gems', async () => { + await createVmBundleForTest(); + const app = createWorker({ password: 'my_password' }); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ 'content-type': 'application/x-www-form-urlencoded' }) + .payload( + querystring.stringify({ + renderingRequest: 'ReactOnRails.dummy', + protocolVersion, + gemVersion, + railsEnv, + password: 'my_password', + }), + ) + .end(); + + expect(res.statusCode).toBe(200); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest returns actionable error when renderingRequest is missing', async () => { const app = worker({ serverBundleCachePath: serverBundleCachePathForTest(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d1752436d..de9cde98f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: packages/react-on-rails-pro-node-renderer: dependencies: + '@fastify/formbody': + specifier: ^7.4.0 || ^8.0.2 + version: 8.0.2 '@fastify/multipart': specifier: ^8.3.1 || ^9.0.3 version: 9.3.0 @@ -2009,6 +2012,9 @@ packages: '@fastify/fast-json-stringify-compiler@5.0.3': resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} @@ -11643,6 +11649,11 @@ snapshots: dependencies: fast-json-stringify: 6.3.0 + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + '@fastify/forwarded@3.0.1': {} '@fastify/merge-json-schemas@0.2.1': diff --git a/react_on_rails_pro/Gemfile.lock b/react_on_rails_pro/Gemfile.lock index 5c64a98a65..9db8a9c228 100644 --- a/react_on_rails_pro/Gemfile.lock +++ b/react_on_rails_pro/Gemfile.lock @@ -512,4 +512,4 @@ DEPENDENCIES yard BUNDLED WITH - 4.0.10 + 2.5.4 diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index c61dde55bc..4985f5832e 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -350,22 +350,27 @@ def perform_render_request(path, js_code, form:, send_bundle:, stream: false) end end + # The field list here must stay in sync with common_form_data / form_with_code and + # the Node renderer's normalizeRawRenderRequest (node renderer's worker.ts); an + # unmapped field raises ArgumentError below. Every value is sanitized because these + # body fields become raw HTTP header values. def raw_render_request(js_code, form) metadata = form.except("renderingRequest") headers = [ ["content-type", RAW_RENDER_CONTENT_TYPE], - ["#{RAW_RENDER_HEADER_PREFIX}protocol-version", metadata.delete("protocolVersion").to_s], - ["#{RAW_RENDER_HEADER_PREFIX}gem-version", metadata.delete("gemVersion").to_s], - ["#{RAW_RENDER_HEADER_PREFIX}rails-env", metadata.delete("railsEnv").to_s], + ["#{RAW_RENDER_HEADER_PREFIX}protocol-version", + sanitize_raw_render_header(metadata.delete("protocolVersion"))], + ["#{RAW_RENDER_HEADER_PREFIX}gem-version", sanitize_raw_render_header(metadata.delete("gemVersion"))], + ["#{RAW_RENDER_HEADER_PREFIX}rails-env", sanitize_raw_render_header(metadata.delete("railsEnv"))], [ "#{RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps", - JSON.generate(Array(metadata.delete("dependencyBundleTimestamps"))) + sanitize_raw_render_header(JSON.generate(Array(metadata.delete("dependencyBundleTimestamps")))) ] ] password = metadata.delete("password") headers << ["authorization", "Bearer #{sanitize_raw_render_header(password)}"] if password if metadata.key?("rscStreamObservability") - observability = metadata.delete("rscStreamObservability").to_s + observability = sanitize_raw_render_header(metadata.delete("rscStreamObservability")) headers << ["#{RAW_RENDER_HEADER_PREFIX}rsc-stream-observability", observability] end raise ArgumentError, "Unsupported raw render metadata: #{metadata.keys.join(', ')}" unless metadata.empty? diff --git a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb index d9d7a443bb..43c4aedd45 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb @@ -281,6 +281,23 @@ def mock_response(status:, chunks: []) ) end.to raise_error(ArgumentError, /cannot contain newlines/) end + + it "rejects header injection through non-password metadata" do + expect do + described_class.send( + :raw_render_request, + "ReactOnRails.dummy", + { + "renderingRequest" => "ReactOnRails.dummy", + "protocolVersion" => "2.0.0", + "gemVersion" => "16.0.0", + "password" => "secret", + "railsEnv" => "test\r\ninjected: true", + "dependencyBundleTimestamps" => [] + } + ) + end.to raise_error(ArgumentError, /cannot contain newlines/) + end end describe ".render_code" do From 3b7c5f18b7018920e8645d9083d145c6721bd224 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 11 Jul 2026 23:55:48 -1000 Subject: [PATCH 4/5] Forward gemVersion/railsEnv to raw render prechecks rawRenderPrecheckBody omitted the fields checkProtocolVersion uses for the non-production gem/renderer version-mismatch 412, silently skipping that guard on the raw path (the common non-bundle render path). Forward both headers and add a test asserting a mismatched gem version still 412s outside production. Co-Authored-By: Claude Fable 5 --- .../src/worker.ts | 11 ++++++++--- .../tests/worker.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-on-rails-pro-node-renderer/src/worker.ts b/packages/react-on-rails-pro-node-renderer/src/worker.ts index 38be7a54a4..bee31c3377 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -501,13 +501,18 @@ const normalizeRawRenderRequest = ( }; }; -// Extracts only the precheck fields, without shape validation, so protocol and password -// checks run before normalizeRawRenderRequest's 400s; a malformed but unauthenticated -// raw request must fail auth first, matching the parsed-body path and /asset-exists. +// Extracts only the precheck fields, without shape validation, so protocol, version, and +// password checks run before normalizeRawRenderRequest's 400s; a malformed but +// unauthenticated raw request must fail auth first, matching the parsed-body path and +// /asset-exists. Must include every field checkProtocolVersion reads (gemVersion and +// railsEnv drive the non-production version-mismatch 412), since prechecks run only once +// on this path. const rawRenderPrecheckBody = (headers: RawRenderHeaders) => { const authorization = scalarHeader(headers, 'authorization'); return { protocolVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`), + gemVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`), + railsEnv: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`), password: authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization, }; }; diff --git a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts index 1028603c7f..147be74b31 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts @@ -275,6 +275,25 @@ describe('worker', () => { expect(res.payload).toBe('Wrong password'); }); + test('POST raw render request rejects a mismatched gem version outside production', async () => { + const app = createWorker(); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ + 'content-type': 'application/vnd.react-on-rails.render-request+javascript', + 'x-react-on-rails-pro-protocol-version': protocolVersion, + 'x-react-on-rails-pro-gem-version': '0.0.1', + 'x-react-on-rails-pro-rails-env': railsEnv, + }) + .payload('ReactOnRails.dummy') + .end(); + + expect(res.statusCode).toBe(412); + expect(res.payload).toContain('does not match node renderer version'); + }); + test('POST render request still accepts application/x-www-form-urlencoded bodies from older gems', async () => { await createVmBundleForTest(); const app = createWorker({ password: 'my_password' }); From ab0b98c6a50b5e37a7043e6176ab40835cb859d5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 12 Jul 2026 00:50:16 -1000 Subject: [PATCH 5/5] Keep precheck diagnostics truthful; lock string response Content-Type at the type level - rawRenderPrecheckBody no longer defines keys for absent headers, so the protocol-version 412's "received fields" list only names fields the client actually sent; test added for the no-metadata case - ResponseResult marks Content-Type as never: setResponse intentionally forces text/plain on string payloads (reflected-XSS hardening), so a future caller setting a custom Content-Type now fails to compile instead of being silently clobbered Co-Authored-By: Claude Fable 5 --- .../src/shared/utils.ts | 4 +++- .../src/worker.ts | 21 +++++++++++++------ .../tests/worker.test.ts | 14 +++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts b/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts index b2b85a1c16..2128f6f77c 100644 --- a/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts +++ b/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts @@ -63,7 +63,9 @@ export function smartTrim(value: unknown, maxLength = getConfig().maxDebugSnippe export interface ResponseResult { headers: { 'Cache-Control'?: string; - 'Content-Type'?: string; + // Content-Type is not caller-settable: setResponse forces text/plain on every string + // payload (reflected-XSS hardening) and otherwise lets Fastify infer it. + 'Content-Type'?: never; 'X-Content-Type-Options'?: string; [key: string]: string | undefined; }; diff --git a/packages/react-on-rails-pro-node-renderer/src/worker.ts b/packages/react-on-rails-pro-node-renderer/src/worker.ts index bee31c3377..7a4df7e9e4 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -506,15 +506,24 @@ const normalizeRawRenderRequest = ( // unauthenticated raw request must fail auth first, matching the parsed-body path and // /asset-exists. Must include every field checkProtocolVersion reads (gemVersion and // railsEnv drive the non-production version-mismatch 412), since prechecks run only once -// on this path. +// on this path. Absent headers must not produce keys at all, so the protocol-version +// diagnostic's "received fields" list only names fields the client actually sent. const rawRenderPrecheckBody = (headers: RawRenderHeaders) => { const authorization = scalarHeader(headers, 'authorization'); - return { - protocolVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`), - gemVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`), - railsEnv: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`), - password: authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization, + const body: Record = {}; + const assign = (key: string, value: string | undefined) => { + if (value !== undefined) { + body[key] = value; + } }; + assign('protocolVersion', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`)); + assign('gemVersion', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`)); + assign('railsEnv', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`)); + assign( + 'password', + authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization, + ); + return body; }; function discardMultipartFile(part: MultipartFile) { diff --git a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts index 147be74b31..5f7869de2f 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts @@ -294,6 +294,20 @@ describe('worker', () => { expect(res.payload).toContain('does not match node renderer version'); }); + test('POST raw render request reports no received fields when metadata headers are missing', async () => { + const app = createWorker(); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`) + .headers({ 'content-type': 'application/vnd.react-on-rails.render-request+javascript' }) + .payload('ReactOnRails.dummy') + .end(); + + expect(res.statusCode).toBe(412); + expect(res.payload).toContain('MISSING (received fields: (none))'); + }); + test('POST render request still accepts application/x-www-form-urlencoded bodies from older gems', async () => { await createVmBundleForTest(); const app = createWorker({ password: 'my_password' });