Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 32 additions & 5 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def server_render_js(js_expression, options = {})
}
}

consoleReplayScript = ReactOnRails.buildConsoleReplay();
consoleReplayScript = ReactOnRails.getConsoleReplayScript();

return JSON.stringify({
html: htmlResult,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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
)
Expand All @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions packages/react-on-rails/src/RenderUtils.ts
Original file line number Diff line number Diff line change
@@ -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 `
<script id="${scriptId}">
<script id="${scriptId}"${nonceAttr}>
${scriptBody}
</script>`;
}
6 changes: 5 additions & 1 deletion packages/react-on-rails/src/base/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 };
},
Expand Down
9 changes: 7 additions & 2 deletions packages/react-on-rails/src/buildConsoleReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
8 changes: 4 additions & 4 deletions packages/react-on-rails/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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));
}

Expand Down
6 changes: 6 additions & 0 deletions packages/react-on-rails/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
48 changes: 48 additions & 0 deletions packages/react-on-rails/tests/buildConsoleReplay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script id="consoleReplayLog" nonce="abc123">');
expect(actual).toContain('console.log.apply(console, ["test message"]);');
});

it('buildConsoleReplay returns empty string when no console messages', () => {
console.history = [];
const actual = buildConsoleReplay(undefined, 0, 'abc123');

expect(actual).toEqual('');
});

it('consoleReplay returns only JavaScript without script tags', () => {
console.history = [
{ arguments: ['message 1'], level: 'log' },
{ arguments: ['message 2'], level: 'error' },
];
const actual = consoleReplay();

// Should not contain script tags
expect(actual).not.toContain('<script');
expect(actual).not.toContain('</script>');

// 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=/);
});
});
3 changes: 2 additions & 1 deletion react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,13 @@ def response; end
let(:chunks) do
[
{ html: "<div>Chunk 1: Stream React Server Components</div>",
consoleReplayScript: "<script>console.log.apply(console, " \
"['Chunk 1: Console Message'])</script>" },
consoleReplayScript: "console.log.apply(console, " \
"['Chunk 1: Console Message'])" },
{ html: "<div>Chunk 2: More content</div>",
consoleReplayScript: "<script>console.log.apply(console, " \
consoleReplayScript: "console.log.apply(console, " \
"['Chunk 2: Console Message']);\n" \
"console.error.apply(console, " \
"['Chunk 2: Console Error']);</script>" },
"['Chunk 2: Console Error']);" },
{ html: "<div>Chunk 3: Final content</div>", consoleReplayScript: "" }
]
end
Expand Down Expand Up @@ -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 = "<script id=\"consoleReplayLog\">#{script}</script>"
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
Expand All @@ -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? ? "<script id=\"consoleReplayLog\">#{script}</script>" : ""
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,20 @@
console.log.apply(console, ["[SERVER] Script4\\"</div>\\"(/script <script>alert('WTF4')(/script>"]);
console.log.apply(console, ["[SERVER] Script5:\\"</div>\\"(/script> <script>alert('WTF5')(/script>"]);
console.log.apply(console, ["[SERVER] railsContext.serverSide is ","true"]);
console.log.apply(console, ["[SERVER] RENDERED ReduxSharedStoreApp to dom node with id: ReduxSharedStoreApp-react-component-1"]);
JS

expected_lines = expected.split("\n")

script_node = html_nodes.css("script#consoleReplayLog")
script_lines = script_node.text.split("\n")
# When multiple components with replay_console are rendered, each creates its own script tag
# with id="consoleReplayLog". Nokogiri's .text concatenates them without separators, which
# breaks parsing. Instead, we explicitly join them with newlines.
script_nodes = html_nodes.css("script#consoleReplayLog")
script_text = script_nodes.map(&:text).join("\n")
script_lines = script_text.split("\n")

# First item is a blank line since expected script starts form "\n":
script_lines.shift
# Remove leading blank line if present (old format had it, new format doesn't)
script_lines.shift if script_lines.first && script_lines.first.empty?

# Create external iterators for expected and found console replay script lines:
expected_lines_iterator = expected_lines.to_enum
Expand Down
Loading
Loading