Skip to content

Commit fda659f

Browse files
justin808claude
andcommitted
Improve bundle path resolution with secure server bundle locations
This commit adds server bundle security features to prevent server bundles from being served from public directories when configured appropriately. Key changes: - Add server_bundle_output_path configuration for private bundle locations - Add enforce_private_server_bundles security enforcement option - Improve bundle_js_file_path method to check for server bundles first - Add public_bundles_full_path method with deprecation for old name - Add comprehensive validation for security configuration - Add helper methods for server bundle detection and security checks The implementation maintains backwards compatibility while providing enhanced security for server-side rendering bundles. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 2f11606 commit fda659f

File tree

3 files changed

+138
-9
lines changed

3 files changed

+138
-9
lines changed

lib/react_on_rails/configuration.rb

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ def self.configuration
5252
# If exceeded, an error will be thrown for server-side rendered components not registered on the client.
5353
# Set to 0 to disable the timeout and wait indefinitely for component registration.
5454
component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT,
55-
generated_component_packs_loading_strategy: nil
55+
generated_component_packs_loading_strategy: nil,
56+
# Server bundle security options
57+
server_bundle_output_path: nil,
58+
enforce_private_server_bundles: false
5659
)
5760
end
5861

@@ -68,7 +71,8 @@ class Configuration
6871
:same_bundle_for_client_and_server, :rendering_props_extension,
6972
:make_generated_server_bundle_the_entrypoint,
7073
:generated_component_packs_loading_strategy, :immediate_hydration, :rsc_bundle_js_file,
71-
:react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout
74+
:react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout,
75+
:server_bundle_output_path, :enforce_private_server_bundles
7276

7377
# rubocop:disable Metrics/AbcSize
7478
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
@@ -85,7 +89,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
8589
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
8690
components_subdirectory: nil, auto_load_bundle: nil, immediate_hydration: nil,
8791
rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil,
88-
component_registry_timeout: nil)
92+
component_registry_timeout: nil, server_bundle_output_path: nil, enforce_private_server_bundles: nil)
8993
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
9094
self.generated_assets_dirs = generated_assets_dirs
9195
self.generated_assets_dir = generated_assets_dir
@@ -130,6 +134,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
130134
self.defer_generated_component_packs = defer_generated_component_packs
131135
self.immediate_hydration = immediate_hydration
132136
self.generated_component_packs_loading_strategy = generated_component_packs_loading_strategy
137+
self.server_bundle_output_path = server_bundle_output_path
138+
self.enforce_private_server_bundles = enforce_private_server_bundles
133139
end
134140
# rubocop:enable Metrics/AbcSize
135141

@@ -146,6 +152,7 @@ def setup_config_values
146152
adjust_precompile_task
147153
check_component_registry_timeout
148154
validate_generated_component_packs_loading_strategy
155+
validate_enforce_private_server_bundles
149156
end
150157

151158
private
@@ -194,6 +201,30 @@ def validate_generated_component_packs_loading_strategy
194201
raise ReactOnRails::Error, "generated_component_packs_loading_strategy must be either :async, :defer, or :sync"
195202
end
196203

204+
def validate_enforce_private_server_bundles
205+
return unless enforce_private_server_bundles
206+
207+
# Check if server_bundle_output_path is nil
208+
if server_bundle_output_path.nil?
209+
raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \
210+
"server_bundle_output_path is nil. Please set server_bundle_output_path " \
211+
"to a directory outside of the public directory."
212+
end
213+
214+
# Check if server_bundle_output_path is inside public directory
215+
# Skip validation if Rails.root is not available (e.g., in tests)
216+
return unless defined?(Rails) && Rails.root
217+
218+
public_path = Rails.root.join("public").to_s
219+
server_output_path = File.expand_path(server_bundle_output_path, Rails.root.to_s)
220+
221+
return unless server_output_path.start_with?(public_path)
222+
223+
raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \
224+
"server_bundle_output_path (#{server_bundle_output_path}) is inside " \
225+
"the public directory. Please set it to a directory outside of public."
226+
end
227+
197228
def check_autobundling_requirements
198229
raise_missing_components_subdirectory if auto_load_bundle && !components_subdirectory.present?
199230
return unless components_subdirectory.present?

lib/react_on_rails/utils.rb

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ def self.server_bundle_path_is_http?
7171
end
7272

7373
def self.bundle_js_file_path(bundle_name)
74+
# Check if this is a server bundle with configured output path - skip manifest lookup
75+
if server_bundle?(bundle_name)
76+
config = ReactOnRails.configuration
77+
root_path = Rails.root || "."
78+
79+
# Use configured server_bundle_output_path if present
80+
if config.server_bundle_output_path.present?
81+
return File.expand_path(File.join(root_path, config.server_bundle_output_path, bundle_name))
82+
end
83+
end
84+
7485
# Either:
7586
# 1. Using same bundle for both server and client, so server bundle will be hashed in manifest
7687
# 2. Using a different bundle (different Webpack config), so file is not hashed, and
@@ -84,15 +95,12 @@ def self.bundle_js_file_path(bundle_name)
8495
# Default to the non-hashed name in the specified output directory, which, for legacy
8596
# React on Rails, this is the output directory picked up by the asset pipeline.
8697
# For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file.
87-
File.join(generated_assets_full_path, bundle_name)
98+
File.join(public_bundles_full_path, bundle_name)
8899
else
89100
begin
90101
ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name)
91102
rescue Shakapacker::Manifest::MissingEntryError
92-
File.expand_path(
93-
File.join(ReactOnRails::PackerUtils.packer_public_output_path,
94-
bundle_name)
95-
)
103+
handle_missing_manifest_entry(bundle_name)
96104
end
97105
end
98106
end
@@ -165,10 +173,15 @@ def self.using_packer_source_path_is_not_defined_and_custom_node_modules?
165173
ReactOnRails.configuration.node_modules_location.present?
166174
end
167175

168-
def self.generated_assets_full_path
176+
def self.public_bundles_full_path
169177
ReactOnRails::PackerUtils.packer_public_output_path
170178
end
171179

180+
# DEPRECATED: Use public_bundles_full_path for clarity about public vs private bundle paths
181+
def self.generated_assets_full_path
182+
public_bundles_full_path
183+
end
184+
172185
def self.gem_available?(name)
173186
Gem.loaded_specs[name].present?
174187
rescue Gem::LoadError
@@ -274,5 +287,29 @@ def self.default_troubleshooting_section
274287
• 📖 Discussions: https://github.com/shakacode/react_on_rails/discussions
275288
DEFAULT
276289
end
290+
291+
def self.server_bundle?(bundle_name)
292+
bundle_name == ReactOnRails.configuration.server_bundle_js_file ||
293+
bundle_name == ReactOnRails.configuration.rsc_bundle_js_file
294+
end
295+
296+
def self.enforce_private_server_bundles?
297+
ReactOnRails.configuration.enforce_private_server_bundles
298+
end
299+
300+
def self.handle_missing_manifest_entry(bundle_name)
301+
# For server bundles with enforcement enabled, don't fall back to public paths
302+
if server_bundle?(bundle_name) && enforce_private_server_bundles?
303+
raise Shakapacker::Manifest::MissingEntryError,
304+
"Server bundle '#{bundle_name}' not found in manifest and " \
305+
"enforce_private_server_bundles is enabled. " \
306+
"Ensure server bundle is built and server_bundle_output_path is configured correctly."
307+
end
308+
309+
# Default fallback to non-hashed path in public directory
310+
File.join(public_bundles_full_path, bundle_name)
311+
end
312+
313+
private_class_method :server_bundle?, :enforce_private_server_bundles?, :handle_missing_manifest_entry
277314
end
278315
end

spec/react_on_rails/configuration_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,67 @@ module ReactOnRails
459459
end
460460
end
461461
end
462+
463+
describe "server bundle security configuration" do
464+
before do
465+
allow(Rails).to receive(:root).and_return(Pathname.new("/app"))
466+
end
467+
468+
describe ".server_bundle_output_path" do
469+
it "has default value of nil" do
470+
ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock
471+
expect(ReactOnRails.configuration.server_bundle_output_path).to be_nil
472+
end
473+
474+
it "accepts custom path" do
475+
ReactOnRails.configure do |config|
476+
config.server_bundle_output_path = "app/assets/builds"
477+
end
478+
expect(ReactOnRails.configuration.server_bundle_output_path).to eq("app/assets/builds")
479+
end
480+
end
481+
482+
describe ".enforce_private_server_bundles" do
483+
it "has default value of false" do
484+
ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock
485+
expect(ReactOnRails.configuration.enforce_private_server_bundles).to be(false)
486+
end
487+
488+
it "accepts true value" do
489+
ReactOnRails.configure do |config|
490+
config.server_bundle_output_path = "app/assets/builds"
491+
config.enforce_private_server_bundles = true
492+
end
493+
expect(ReactOnRails.configuration.enforce_private_server_bundles).to be(true)
494+
end
495+
496+
it "raises error when enabled without server_bundle_output_path" do
497+
expect do
498+
ReactOnRails.configure do |config|
499+
config.enforce_private_server_bundles = true
500+
end
501+
end.to raise_error(ReactOnRails::Error, /server_bundle_output_path is nil/)
502+
end
503+
504+
it "raises error when server_bundle_output_path is inside public directory" do
505+
expect do
506+
ReactOnRails.configure do |config|
507+
config.server_bundle_output_path = "public/webpack"
508+
config.enforce_private_server_bundles = true
509+
end
510+
end.to raise_error(ReactOnRails::Error, /is inside the public directory/)
511+
end
512+
513+
it "allows server_bundle_output_path outside public directory" do
514+
expect do
515+
ReactOnRails.configure do |config|
516+
config.server_bundle_output_path = "app/assets/builds"
517+
config.enforce_private_server_bundles = true
518+
end
519+
end.not_to raise_error
520+
end
521+
end
522+
end
462523
end
463524
end
464525

0 commit comments

Comments
 (0)