Skip to content

Commit 059a19e

Browse files
justin808claude
andcommitted
Add CSP nonce support for consoleReplay script
Fixes #1930 - Add optional nonce parameter to wrapInScriptTags() and buildConsoleReplay() - Add getConsoleReplayScript() method to return JS without script tags - Update Ruby helper to wrap console script with CSP nonce using content_security_policy_nonce(:script) - Fix bug: change consoleLogScript to consoleReplayScript in server_render_js - Pro helper now also wraps console scripts with nonce This allows the consoleReplay script to work with Content Security Policy without violations when using script-src :self or similar policies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent beb70f0 commit 059a19e

File tree

6 files changed

+43
-11
lines changed

6 files changed

+43
-11
lines changed

lib/react_on_rails/helper.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def server_render_js(js_expression, options = {})
226226
}
227227
}
228228
229-
consoleReplayScript = ReactOnRails.buildConsoleReplay();
229+
consoleReplayScript = ReactOnRails.getConsoleReplayScript();
230230
231231
return JSON.stringify({
232232
html: htmlResult,
@@ -242,8 +242,9 @@ def server_render_js(js_expression, options = {})
242242
.server_render_js_with_console_logging(js_code, render_options)
243243

244244
html = result["html"]
245-
console_log_script = result["consoleLogScript"]
246-
raw("#{html}#{console_log_script if render_options.replay_console}")
245+
console_script = result["consoleReplayScript"]
246+
console_script_tag = wrap_console_script_with_nonce(console_script) if render_options.replay_console
247+
raw("#{html}#{console_script_tag}")
247248
rescue ExecJS::ProgramError => err
248249
raise ReactOnRails::PrerenderError.new(component_name: "N/A (server_render_js called)",
249250
err: err,
@@ -394,7 +395,7 @@ def build_react_component_result_for_server_rendered_string(
394395
server_rendered_html.html_safe,
395396
content_tag_options)
396397

397-
result_console_script = render_options.replay_console ? console_script : ""
398+
result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
398399
result = compose_react_component_html_with_spec_and_console(
399400
component_specification_tag, rendered_output, result_console_script
400401
)
@@ -419,7 +420,7 @@ def build_react_component_result_for_server_rendered_hash(
419420
server_rendered_html[COMPONENT_HTML_KEY].html_safe,
420421
content_tag_options)
421422

422-
result_console_script = render_options.replay_console ? console_script : ""
423+
result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
423424
result = compose_react_component_html_with_spec_and_console(
424425
component_specification_tag, rendered_output, result_console_script
425426
)
@@ -436,6 +437,19 @@ def build_react_component_result_for_server_rendered_hash(
436437
)
437438
end
438439

440+
def wrap_console_script_with_nonce(console_script_code)
441+
return "" if console_script_code.blank?
442+
443+
# Get the CSP nonce if available
444+
nonce = content_security_policy_nonce(:script) if respond_to?(:content_security_policy_nonce)
445+
446+
# Build the script tag with nonce if available
447+
script_options = { id: "consoleReplayLog" }
448+
script_options[:nonce] = nonce if nonce.present?
449+
450+
content_tag(:script, console_script_code.html_safe, script_options)
451+
end
452+
439453
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
440454
console_script)
441455
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// eslint-disable-next-line import/prefer-default-export -- only one export for now, but others may be added later
2-
export function wrapInScriptTags(scriptId: string, scriptBody: string): string {
2+
export function wrapInScriptTags(scriptId: string, scriptBody: string, nonce?: string): string {
33
if (!scriptBody) {
44
return '';
55
}
66

7+
const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
8+
79
return `
8-
<script id="${scriptId}">
10+
<script id="${scriptId}"${nonceAttr}>
911
${scriptBody}
1012
</script>`;
1113
}

packages/react-on-rails/src/base/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
ReactOnRailsInternal,
1111
} from '../types/index.ts';
1212
import * as Authenticity from '../Authenticity.ts';
13-
import buildConsoleReplay from '../buildConsoleReplay.ts';
13+
import buildConsoleReplay, { consoleReplay } from '../buildConsoleReplay.ts';
1414
import reactHydrateOrRender from '../reactHydrateOrRender.ts';
1515
import createReactOutput from '../createReactOutput.ts';
1616

@@ -175,6 +175,10 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
175175
return buildConsoleReplay();
176176
},
177177

178+
getConsoleReplayScript(): string {
179+
return consoleReplay();
180+
},
181+
178182
resetOptions(): void {
179183
this.options = { ...DEFAULT_OPTIONS };
180184
},

packages/react-on-rails/src/buildConsoleReplay.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ declare global {
1010
}
1111
}
1212

13-
/** @internal Exported only for tests */
13+
/**
14+
* Returns the console replay JavaScript code without wrapping it in script tags.
15+
* This is useful when you want to wrap the code in script tags yourself (e.g., with a CSP nonce).
16+
* @internal Exported for tests and for Ruby helper to wrap with nonce
17+
*/
1418
export function consoleReplay(
1519
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
1620
numberOfMessagesToSkip: number = 0,
@@ -53,10 +57,11 @@ export function consoleReplay(
5357
export default function buildConsoleReplay(
5458
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
5559
numberOfMessagesToSkip: number = 0,
60+
nonce?: string,
5661
): string {
5762
const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
5863
if (consoleReplayJS.length === 0) {
5964
return '';
6065
}
61-
return wrapInScriptTags('consoleReplayLog', consoleReplayJS);
66+
return wrapInScriptTags('consoleReplayLog', consoleReplayJS, nonce);
6267
}

packages/react-on-rails/src/types/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,14 @@ export interface ReactOnRailsInternal extends ReactOnRails {
437437
handleError(options: ErrorOptions): string | undefined;
438438
/**
439439
* Used by Rails server rendering to replay console messages.
440+
* Returns the console replay script wrapped in script tags.
440441
*/
441442
buildConsoleReplay(): string;
443+
/**
444+
* Returns the console replay JavaScript code without wrapping it in script tags.
445+
* Useful when you need to add CSP nonce or other attributes to the script tag.
446+
*/
447+
getConsoleReplayScript(): string;
442448
/**
443449
* Get a Map containing all registered components. Useful for debugging.
444450
*/

react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,8 @@ def build_react_component_result_for_server_streamed_content(
347347
render_options: render_options
348348
)
349349
else
350-
result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : ""
350+
console_script = chunk_json_result["consoleReplayScript"]
351+
result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
351352
# No need to prepend component_specification_tag or add rails context again
352353
# as they're already included in the first chunk
353354
compose_react_component_html_with_spec_and_console(

0 commit comments

Comments
 (0)