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
26 changes: 15 additions & 11 deletions react_on_rails/lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -480,23 +480,27 @@ def build_react_component_result_for_server_rendered_hash(
)
end

# Returns the CSP script nonce for the current request, or nil if CSP is not enabled.
# Rails 5.2-6.0 use content_security_policy_nonce with no arguments.
# Rails 6.1+ accept an optional directive argument.
def csp_nonce
return unless 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

# 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
nonce = csp_nonce

# Build the script tag with nonce if available
script_options = { id: "consoleReplayLog" }
Expand Down
15 changes: 11 additions & 4 deletions react_on_rails/lib/react_on_rails/pro_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ def generate_component_script(render_options)
spec_tag = if render_options.immediate_hydration
# Escape dom_id for JavaScript context
escaped_dom_id = escape_javascript(render_options.dom_id)
nonce = csp_nonce
script_options = nonce.present? ? { nonce: nonce } : {}
immediate_script = content_tag(:script, %(
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsComponentLoaded('#{escaped_dom_id}');
).html_safe)
).html_safe, script_options)
"#{component_specification_tag}\n#{immediate_script}"
else
component_specification_tag
Expand All @@ -49,9 +51,14 @@ def generate_store_script(redux_store_data)
store_hydration_scripts = if redux_store_data[:immediate_hydration]
# Escape store_name for JavaScript context
escaped_store_name = escape_javascript(redux_store_data[:store_name])
immediate_script = content_tag(:script, <<~JS.strip_heredoc.html_safe
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{escaped_store_name}');
JS
nonce = csp_nonce
script_options = nonce.present? ? { nonce: nonce } : {}
immediate_script = content_tag(
:script,
<<~JS.strip_heredoc.html_safe,
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{escaped_store_name}');
JS
script_options
)
"#{store_hydration_data}\n#{immediate_script}"
else
Expand Down
196 changes: 172 additions & 24 deletions react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class PlainReactOnRailsHelper
include ReactOnRailsHelper
include ActionView::Helpers::TagHelper
include ActionView::Helpers::JavaScriptHelper
end

# rubocop:disable Metrics/BlockLength
Expand Down Expand Up @@ -752,33 +753,26 @@ def helper.append_javascript_pack_tag(name, **options)
end
end

describe "#wrap_console_script_with_nonce" do
describe "#csp_nonce" do
let(:helper) { PlainReactOnRailsHelper.new }
let(:console_script) { "console.log.apply(console, ['[SERVER] test message']);" }

context "when CSP nonce is available" do
before do
# content_security_policy_nonce is a Rails method not present on PlainReactOnRailsHelper,
# so we define it on the singleton to simulate a Rails view context with CSP enabled.
def helper.respond_to?(method_name, *args)
return true if method_name == :content_security_policy_nonce

super
end

def helper.content_security_policy_nonce(_directive = nil)
"abc123"
"test-nonce-123"
end
end

it "wraps script with nonce attribute" do
result = helper.send(:wrap_console_script_with_nonce, console_script)
expect(result).to include('nonce="abc123"')
expect(result).to include('id="consoleReplayLog"')
expect(result).to include(console_script)
end

it "creates a valid script tag" do
result = helper.send(:wrap_console_script_with_nonce, console_script)
expect(result).to match(%r{<script.*id="consoleReplayLog".*>.*</script>})
it "returns the nonce value" do
expect(helper.send(:csp_nonce)).to eq("test-nonce-123")
end
end

Expand All @@ -788,16 +782,15 @@ def helper.content_security_policy_nonce(_directive = nil)
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)
it "returns nil" do
expect(helper.send(:csp_nonce)).to be_nil
end
end

context "with Rails 5.2-6.0 compatibility (ArgumentError fallback)" do
before do
# Simulate an older Rails where content_security_policy_nonce raises ArgumentError
# when called with arguments.
def helper.respond_to?(method_name, *args)
return true if method_name == :content_security_policy_nonce

Expand All @@ -807,13 +800,170 @@ def helper.respond_to?(method_name, *args)
def helper.content_security_policy_nonce(*args)
raise ArgumentError if args.any?

"fallback123"
"fallback-nonce"
end
end

it "falls back to no-argument method" do
expect(helper.send(:csp_nonce)).to eq("fallback-nonce")
end
end
end

describe "#generate_component_script" do
let(:helper) { PlainReactOnRailsHelper.new }

let(:render_options) do
instance_double(
ReactOnRails::ReactComponent::RenderOptions,
client_props: { name: "World" },
dom_id: "HelloWorld-react-component-0",
react_component_name: "HelloWorld",
trace: false,
store_dependencies: nil,
immediate_hydration: true
)
end

context "when CSP nonce is available" do
before do
allow(helper).to receive(:csp_nonce).and_return("component-nonce-abc")
end

it "adds nonce to the immediate hydration script" do
result = helper.send(:generate_component_script, render_options)
expect(result).to include('nonce="component-nonce-abc"')
expect(result).to include("reactOnRailsComponentLoaded")
end

it "does not add nonce to the application/json script" do
result = helper.send(:generate_component_script, render_options)
json_tag_match = result.match(%r{<script type="application/json"[^>]*>})
expect(json_tag_match.to_s).not_to include("nonce=")
end
end

context "when CSP is not configured" do
before do
allow(helper).to receive(:csp_nonce).and_return(nil)
end

it "does not add nonce to the immediate hydration script" do
result = helper.send(:generate_component_script, render_options)
expect(result).not_to include("nonce=")
expect(result).to include("reactOnRailsComponentLoaded")
end
end

context "when immediate_hydration is disabled" do
let(:render_options) do
instance_double(
ReactOnRails::ReactComponent::RenderOptions,
client_props: { name: "World" },
dom_id: "HelloWorld-react-component-0",
react_component_name: "HelloWorld",
trace: false,
store_dependencies: nil,
immediate_hydration: false
)
end

it "does not include an immediate hydration script" do
result = helper.send(:generate_component_script, render_options)
expect(result).not_to include("reactOnRailsComponentLoaded")
end
end
end

describe "#generate_store_script" do
let(:helper) { PlainReactOnRailsHelper.new }

let(:redux_store_data) do
{
props: { count: 0 },
store_name: "MyStore",
immediate_hydration: true
}
end

context "when CSP nonce is available" do
before do
allow(helper).to receive(:csp_nonce).and_return("store-nonce-xyz")
end

it "adds nonce to the immediate hydration script" do
result = helper.send(:generate_store_script, redux_store_data)
expect(result).to include('nonce="store-nonce-xyz"')
expect(result).to include("reactOnRailsStoreLoaded")
end

it "does not add nonce to the application/json script" do
result = helper.send(:generate_store_script, redux_store_data)
json_tag_match = result.match(%r{<script type="application/json"[^>]*>})
expect(json_tag_match.to_s).not_to include("nonce=")
end
end

context "when CSP is not configured" do
before do
allow(helper).to receive(:csp_nonce).and_return(nil)
end

it "does not add nonce to the immediate hydration script" do
result = helper.send(:generate_store_script, redux_store_data)
expect(result).not_to include("nonce=")
expect(result).to include("reactOnRailsStoreLoaded")
end
end

context "when immediate_hydration is disabled" do
let(:redux_store_data) do
{
props: { count: 0 },
store_name: "MyStore",
immediate_hydration: false
}
end

it "does not include an immediate hydration script" do
result = helper.send(:generate_store_script, redux_store_data)
expect(result).not_to include("reactOnRailsStoreLoaded")
end
end
end

describe "#wrap_console_script_with_nonce" do
let(:helper) { PlainReactOnRailsHelper.new }
let(:console_script) { "console.log.apply(console, ['[SERVER] test message']);" }

context "when CSP nonce is available" do
before do
allow(helper).to receive(:csp_nonce).and_return("abc123")
end

it "wraps script with nonce attribute" do
result = helper.send(:wrap_console_script_with_nonce, console_script)
expect(result).to include('nonce="abc123"')
expect(result).to include('id="consoleReplayLog"')
expect(result).to include(console_script)
end

it "creates a valid script tag" do
result = helper.send(:wrap_console_script_with_nonce, console_script)
expect(result).to match(%r{<script.*id="consoleReplayLog".*>.*</script>})
end
end

context "when CSP is not configured" do
before do
allow(helper).to receive(:csp_nonce).and_return(nil)
end

it "wraps script without nonce attribute" do
result = helper.send(:wrap_console_script_with_nonce, console_script)
expect(result).to include('nonce="fallback123"')
expect(result).not_to include("nonce=")
expect(result).to include('id="consoleReplayLog"')
expect(result).to include(console_script)
end
end

Expand Down Expand Up @@ -841,8 +991,7 @@ def helper.content_security_policy_nonce(*args)
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)
allow(helper).to receive(:csp_nonce).and_return(nil)
end

it "preserves newlines in multi-line script" do
Expand All @@ -859,8 +1008,7 @@ def helper.content_security_policy_nonce(*args)
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)
allow(helper).to receive(:csp_nonce).and_return(nil)
end

it "properly escapes content in script tag" do
Expand Down
Loading