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/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) 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 05a56cb9c7..7a4df7e9e4 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -108,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( @@ -399,6 +381,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 +443,89 @@ 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 }), + }, + }; +}; + +// 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. 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'); + 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) { part.file.resume(); // eslint-disable-next-line no-param-reassign @@ -534,7 +601,10 @@ export default function run(config: Partial) { } }); - // Supports application/x-www-form-urlencoded + // 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, { @@ -584,6 +654,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,9 +672,31 @@ 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); - if (precheckResult) { - await setResponse(precheckResult, 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( + badRequestResponseResult(normalizedRequest.error ?? 'Invalid raw render request.'), + res, + ); + return; + } + 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; } @@ -615,11 +711,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..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 @@ -214,6 +214,123 @@ 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 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 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 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' }); + + 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/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..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 @@ -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,48 @@ 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 + + # 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", + 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", + 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 = 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? + + { 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..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 @@ -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,101 @@ 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 + + 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 + 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)