diff --git a/CHANGELOG.md b/CHANGELOG.md index 28dee3ba1b..5dd0b13a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### Added + +- **CSP Nonce Support for Console Replay**: Added Content Security Policy (CSP) nonce support for the `consoleReplay` script generated during server-side rendering. When Rails CSP is configured, the console replay script will automatically include the nonce attribute, allowing it to execute under restrictive CSP policies like `script-src: 'self'`. The implementation includes cross-version Rails compatibility (5.2-7.x) and defense-in-depth nonce sanitization to prevent attribute injection attacks. [PR 2059](https://github.com/shakacode/react_on_rails/pull/2059) by [justin808](https://github.com/justin808). + ### [v16.2.0.beta.11] - 2025-11-19 #### Added diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 7c41b02206..ec883db173 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -226,7 +226,7 @@ def server_render_js(js_expression, options = {}) } } - consoleReplayScript = ReactOnRails.buildConsoleReplay(); + consoleReplayScript = ReactOnRails.getConsoleReplayScript(); return JSON.stringify({ html: htmlResult, @@ -242,8 +242,9 @@ def server_render_js(js_expression, options = {}) .server_render_js_with_console_logging(js_code, render_options) html = result["html"] - console_log_script = result["consoleLogScript"] - raw("#{html}#{console_log_script if render_options.replay_console}") + console_script = result["consoleReplayScript"] + console_script_tag = wrap_console_script_with_nonce(console_script) if render_options.replay_console + raw("#{html}#{console_script_tag}") rescue ExecJS::ProgramError => err raise ReactOnRails::PrerenderError.new(component_name: "N/A (server_render_js called)", err: err, @@ -394,7 +395,7 @@ def build_react_component_result_for_server_rendered_string( server_rendered_html.html_safe, content_tag_options) - result_console_script = render_options.replay_console ? console_script : "" + result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : "" result = compose_react_component_html_with_spec_and_console( component_specification_tag, rendered_output, result_console_script ) @@ -419,7 +420,7 @@ def build_react_component_result_for_server_rendered_hash( server_rendered_html[COMPONENT_HTML_KEY].html_safe, content_tag_options) - result_console_script = render_options.replay_console ? console_script : "" + result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : "" result = compose_react_component_html_with_spec_and_console( component_specification_tag, rendered_output, result_console_script ) @@ -436,6 +437,32 @@ def build_react_component_result_for_server_rendered_hash( ) end + # Wraps console replay JavaScript code in a script tag with CSP nonce if available. + # The console_script_code is already sanitized by scriptSanitizedVal() in the JavaScript layer, + # so using html_safe here is secure. + def wrap_console_script_with_nonce(console_script_code) + return "" if console_script_code.blank? + + # Get the CSP nonce if available (Rails 5.2+) + # Rails 5.2-6.0 use content_security_policy_nonce with no arguments + # Rails 6.1+ accept an optional directive argument + nonce = if respond_to?(:content_security_policy_nonce) + begin + content_security_policy_nonce(:script) + rescue ArgumentError + # Fallback for Rails versions that don't accept arguments + content_security_policy_nonce + end + end + + # Build the script tag with nonce if available + script_options = { id: "consoleReplayLog" } + script_options[:nonce] = nonce if nonce.present? + + # Safe to use html_safe because content is pre-sanitized via scriptSanitizedVal() + content_tag(:script, console_script_code.html_safe, script_options) + end + def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script) # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. diff --git a/packages/react-on-rails/src/RenderUtils.ts b/packages/react-on-rails/src/RenderUtils.ts index 9b5ab6cbde..fbfac3eb1d 100644 --- a/packages/react-on-rails/src/RenderUtils.ts +++ b/packages/react-on-rails/src/RenderUtils.ts @@ -1,11 +1,16 @@ // eslint-disable-next-line import/prefer-default-export -- only one export for now, but others may be added later -export function wrapInScriptTags(scriptId: string, scriptBody: string): string { +export function wrapInScriptTags(scriptId: string, scriptBody: string, nonce?: string): string { if (!scriptBody) { return ''; } + // Sanitize nonce to prevent attribute injection attacks + // CSP nonces should be base64 strings, so only allow alphanumeric, +, /, =, -, and _ + const sanitizedNonce = nonce?.replace(/[^a-zA-Z0-9+/=_-]/g, ''); + const nonceAttr = sanitizedNonce ? ` nonce="${sanitizedNonce}"` : ''; + return ` -`; } diff --git a/packages/react-on-rails/src/base/client.ts b/packages/react-on-rails/src/base/client.ts index 44a5b2e954..a4667be7d2 100644 --- a/packages/react-on-rails/src/base/client.ts +++ b/packages/react-on-rails/src/base/client.ts @@ -10,7 +10,7 @@ import type { ReactOnRailsInternal, } from '../types/index.ts'; import * as Authenticity from '../Authenticity.ts'; -import buildConsoleReplay from '../buildConsoleReplay.ts'; +import buildConsoleReplay, { consoleReplay } from '../buildConsoleReplay.ts'; import reactHydrateOrRender from '../reactHydrateOrRender.ts'; import createReactOutput from '../createReactOutput.ts'; @@ -175,6 +175,10 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`); return buildConsoleReplay(); }, + getConsoleReplayScript(): string { + return consoleReplay(); + }, + resetOptions(): void { this.options = { ...DEFAULT_OPTIONS }; }, diff --git a/packages/react-on-rails/src/buildConsoleReplay.ts b/packages/react-on-rails/src/buildConsoleReplay.ts index b8e924dde6..900f1775e5 100644 --- a/packages/react-on-rails/src/buildConsoleReplay.ts +++ b/packages/react-on-rails/src/buildConsoleReplay.ts @@ -10,7 +10,11 @@ declare global { } } -/** @internal Exported only for tests */ +/** + * Returns the console replay JavaScript code without wrapping it in script tags. + * This is useful when you want to wrap the code in script tags yourself (e.g., with a CSP nonce). + * @internal Exported for tests and for Ruby helper to wrap with nonce + */ export function consoleReplay( customConsoleHistory: (typeof console)['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0, @@ -53,10 +57,11 @@ export function consoleReplay( export default function buildConsoleReplay( customConsoleHistory: (typeof console)['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0, + nonce?: string, ): string { const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip); if (consoleReplayJS.length === 0) { return ''; } - return wrapInScriptTags('consoleReplayLog', consoleReplayJS); + return wrapInScriptTags('consoleReplayLog', consoleReplayJS, nonce); } diff --git a/packages/react-on-rails/src/serverRenderReactComponent.ts b/packages/react-on-rails/src/serverRenderReactComponent.ts index 328245518f..ae60cfc711 100644 --- a/packages/react-on-rails/src/serverRenderReactComponent.ts +++ b/packages/react-on-rails/src/serverRenderReactComponent.ts @@ -3,7 +3,7 @@ import { isValidElement, type ReactElement } from 'react'; // ComponentRegistry is accessed via globalThis.ReactOnRails.getComponent for cross-bundle compatibility import createReactOutput from './createReactOutput.ts'; import { isPromise, isServerRenderHash } from './isServerRenderResult.ts'; -import buildConsoleReplay from './buildConsoleReplay.ts'; +import { consoleReplay } from './buildConsoleReplay.ts'; import handleError from './handleError.ts'; import { renderToString } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; @@ -109,11 +109,11 @@ async function createPromiseResult( const consoleHistory = console.history; try { const html = await renderState.result; - const consoleReplayScript = buildConsoleReplay(consoleHistory); + const consoleReplayScript = consoleReplay(consoleHistory); return createResultObject(html, consoleReplayScript, renderState); } catch (e: unknown) { const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors }); - const consoleReplayScript = buildConsoleReplay(consoleHistory); + const consoleReplayScript = consoleReplay(consoleHistory); return createResultObject(errorRenderState.result, consoleReplayScript, errorRenderState); } } @@ -128,7 +128,7 @@ function createFinalResult( return createPromiseResult({ ...renderState, result }, componentName, throwJsErrors); } - const consoleReplayScript = buildConsoleReplay(); + const consoleReplayScript = consoleReplay(); return JSON.stringify(createResultObject(result, consoleReplayScript, renderState)); } diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index f2553a1499..ff3ef4869a 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -437,8 +437,14 @@ export interface ReactOnRailsInternal extends ReactOnRails { handleError(options: ErrorOptions): string | undefined; /** * Used by Rails server rendering to replay console messages. + * Returns the console replay script wrapped in script tags. */ buildConsoleReplay(): string; + /** + * Returns the console replay JavaScript code without wrapping it in script tags. + * Useful when you need to add CSP nonce or other attributes to the script tag. + */ + getConsoleReplayScript(): string; /** * Get a Map containing all registered components. Useful for debugging. */ diff --git a/packages/react-on-rails/tests/buildConsoleReplay.test.js b/packages/react-on-rails/tests/buildConsoleReplay.test.js index 80bb01aae2..4f48ca725b 100644 --- a/packages/react-on-rails/tests/buildConsoleReplay.test.js +++ b/packages/react-on-rails/tests/buildConsoleReplay.test.js @@ -75,4 +75,52 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]); expect(actual).toEqual(expected); }); + + it('buildConsoleReplay adds nonce attribute when provided', () => { + console.history = [{ arguments: ['test message'], level: 'log' }]; + const actual = buildConsoleReplay(undefined, 0, 'abc123'); + + expect(actual).toContain('nonce="abc123"'); + expect(actual).toContain(''); + + // Should contain the JavaScript code + expect(actual).toContain('console.log.apply(console, ["message 1"]);'); + expect(actual).toContain('console.error.apply(console, ["message 2"]);'); + }); + + it('buildConsoleReplay sanitizes nonce to prevent XSS', () => { + console.history = [{ arguments: ['test'], level: 'log' }]; + // Attempt attribute injection attack + const maliciousNonce = 'abc123" onload="alert(1)'; + const actual = buildConsoleReplay(undefined, 0, maliciousNonce); + + // Should strip dangerous characters (quotes, parens, spaces) + // = is kept as it's valid in base64, but the quotes are stripped making it harmless + expect(actual).toContain('nonce="abc123onload=alert1"'); + // Should NOT contain quotes that would close the attribute + expect(actual).not.toContain('nonce="abc123"'); + expect(actual).not.toContain('alert(1)'); + // Verify the dangerous parts (quotes and parens) are removed + expect(actual).not.toMatch(/nonce="[^"]*"[^>]*onload=/); + }); }); diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index b3bcad2a9b..7251ff3fab 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -347,7 +347,8 @@ def build_react_component_result_for_server_streamed_content( render_options: render_options ) else - result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : "" + console_script = chunk_json_result["consoleReplayScript"] + result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : "" # No need to prepend component_specification_tag or add rails context again # as they're already included in the first chunk compose_react_component_html_with_spec_and_console( diff --git a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index ac3debe934..9524783f88 100644 --- a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -295,13 +295,13 @@ def response; end let(:chunks) do [ { html: "
Chunk 1: Stream React Server Components
", - consoleReplayScript: "" }, + consoleReplayScript: "console.log.apply(console, " \ + "['Chunk 1: Console Message'])" }, { html: "
Chunk 2: More content
", - consoleReplayScript: "" }, + "['Chunk 2: Console Error']);" }, { html: "
Chunk 3: Final content
", consoleReplayScript: "" } ] end @@ -373,7 +373,12 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) mock_request_and_response initial_result = stream_react_component(component_name, props: props, **component_options) expect(initial_result).to include(react_component_div_with_initial_chunk) - expect(initial_result).to include(chunks.first[:consoleReplayScript]) + # consoleReplayScript is now wrapped in a script tag with id="consoleReplayLog" + if chunks.first[:consoleReplayScript].present? + script = chunks.first[:consoleReplayScript] + wrapped = "" + expect(initial_result).to include(wrapped) + end expect(initial_result).not_to include("More content", "Final content") expect(chunks_read.count).to eq(1) end @@ -386,9 +391,12 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) expect(fiber).to be_alive second_result = fiber.resume - # regex that matches the html and consoleReplayScript and allows for any amount of whitespace between them + # regex that matches the html and wrapped consoleReplayScript + # Note: consoleReplayScript is now wrapped in a script tag with id="consoleReplayLog" + script = chunks[1][:consoleReplayScript] + wrapped = script.present? ? "" : "" expect(second_result).to match( - /#{Regexp.escape(chunks[1][:html])}\s+#{Regexp.escape(chunks[1][:consoleReplayScript])}/ + /#{Regexp.escape(chunks[1][:html])}\s+#{Regexp.escape(wrapped)}/ ) expect(second_result).not_to include("Stream React Server Components", "Final content") expect(chunks_read.count).to eq(2) diff --git a/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb b/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb index 2a65bda934..9235cfd9c7 100644 --- a/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb @@ -21,15 +21,20 @@ console.log.apply(console, ["[SERVER] Script4\\"\\"(/script }) + end + end + + context "when CSP is not configured" do + before do + allow(helper).to receive(:respond_to?).and_call_original + allow(helper).to receive(:respond_to?).with(:content_security_policy_nonce).and_return(false) + end + + it "wraps script without nonce attribute" do + result = helper.send(:wrap_console_script_with_nonce, console_script) + expect(result).not_to include("nonce=") + expect(result).to include('id="consoleReplayLog"') + expect(result).to include(console_script) + end + end + + context "with Rails 5.2-6.0 compatibility (ArgumentError fallback)" do + before do + def helper.respond_to?(method_name, *args) + return true if method_name == :content_security_policy_nonce + + super + end + + def helper.content_security_policy_nonce(*args) + raise ArgumentError if args.any? + + "fallback123" + end + end + + it "falls back to no-argument method" do + result = helper.send(:wrap_console_script_with_nonce, console_script) + expect(result).to include('nonce="fallback123"') + end + end + + context "with blank input" do + it "returns empty string for empty input" do + expect(helper.send(:wrap_console_script_with_nonce, "")).to eq("") + end + + it "returns empty string for nil input" do + expect(helper.send(:wrap_console_script_with_nonce, nil)).to eq("") + end + + it "returns empty string for whitespace-only input" do + expect(helper.send(:wrap_console_script_with_nonce, " ")).to eq("") + end + end + + context "with multiple console statements" do + let(:multi_line_script) do + <<~JS.strip + console.log.apply(console, ['[SERVER] line 1']); + console.log.apply(console, ['[SERVER] line 2']); + console.error.apply(console, ['[SERVER] error']); + JS + end + + before do + allow(helper).to receive(:respond_to?).and_call_original + allow(helper).to receive(:respond_to?).with(:content_security_policy_nonce).and_return(false) + end + + it "preserves newlines in multi-line script" do + result = helper.send(:wrap_console_script_with_nonce, multi_line_script) + expect(result).to include("line 1") + expect(result).to include("line 2") + expect(result).to include("error") + # Verify newlines are preserved (not collapsed) + expect(result.scan(/console\.(log|error)\.apply/).count).to eq(3) + end + end + + context "with special characters in script" do + let(:script_with_quotes) { %q{console.log.apply(console, ['[SERVER] "quoted" text']);} } + + before do + allow(helper).to receive(:respond_to?).and_call_original + allow(helper).to receive(:respond_to?).with(:content_security_policy_nonce).and_return(false) + end + + it "properly escapes content in script tag" do + result = helper.send(:wrap_console_script_with_nonce, script_with_quotes) + expect(result).to include(script_with_quotes) + expect(result).to match(%r{.*"quoted".*}) + end + end + end end # rubocop:enable Metrics/BlockLength