From fda659ff731ab93d22ccf3011c2e5e063e299dcf Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 22 Sep 2025 18:20:25 -1000 Subject: [PATCH] Improve bundle path resolution with secure server bundle locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/react_on_rails/configuration.rb | 37 ++++++++++++-- lib/react_on_rails/utils.rb | 49 +++++++++++++++--- spec/react_on_rails/configuration_spec.rb | 61 +++++++++++++++++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index d3047a59ed..f39f45d964 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -52,7 +52,10 @@ def self.configuration # If exceeded, an error will be thrown for server-side rendered components not registered on the client. # Set to 0 to disable the timeout and wait indefinitely for component registration. component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT, - generated_component_packs_loading_strategy: nil + generated_component_packs_loading_strategy: nil, + # Server bundle security options + server_bundle_output_path: nil, + enforce_private_server_bundles: false ) end @@ -68,7 +71,8 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :generated_component_packs_loading_strategy, :immediate_hydration, :rsc_bundle_js_file, - :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout + :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout, + :server_bundle_output_path, :enforce_private_server_bundles # rubocop:disable Metrics/AbcSize 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 random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, components_subdirectory: nil, auto_load_bundle: nil, immediate_hydration: nil, rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil, - component_registry_timeout: nil) + component_registry_timeout: nil, server_bundle_output_path: nil, enforce_private_server_bundles: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir @@ -130,6 +134,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.defer_generated_component_packs = defer_generated_component_packs self.immediate_hydration = immediate_hydration self.generated_component_packs_loading_strategy = generated_component_packs_loading_strategy + self.server_bundle_output_path = server_bundle_output_path + self.enforce_private_server_bundles = enforce_private_server_bundles end # rubocop:enable Metrics/AbcSize @@ -146,6 +152,7 @@ def setup_config_values adjust_precompile_task check_component_registry_timeout validate_generated_component_packs_loading_strategy + validate_enforce_private_server_bundles end private @@ -194,6 +201,30 @@ def validate_generated_component_packs_loading_strategy raise ReactOnRails::Error, "generated_component_packs_loading_strategy must be either :async, :defer, or :sync" end + def validate_enforce_private_server_bundles + return unless enforce_private_server_bundles + + # Check if server_bundle_output_path is nil + if server_bundle_output_path.nil? + raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \ + "server_bundle_output_path is nil. Please set server_bundle_output_path " \ + "to a directory outside of the public directory." + end + + # Check if server_bundle_output_path is inside public directory + # Skip validation if Rails.root is not available (e.g., in tests) + return unless defined?(Rails) && Rails.root + + public_path = Rails.root.join("public").to_s + server_output_path = File.expand_path(server_bundle_output_path, Rails.root.to_s) + + return unless server_output_path.start_with?(public_path) + + raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \ + "server_bundle_output_path (#{server_bundle_output_path}) is inside " \ + "the public directory. Please set it to a directory outside of public." + end + def check_autobundling_requirements raise_missing_components_subdirectory if auto_load_bundle && !components_subdirectory.present? return unless components_subdirectory.present? diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index 14458c67d5..d38223fd74 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -71,6 +71,17 @@ def self.server_bundle_path_is_http? end def self.bundle_js_file_path(bundle_name) + # Check if this is a server bundle with configured output path - skip manifest lookup + if server_bundle?(bundle_name) + config = ReactOnRails.configuration + root_path = Rails.root || "." + + # Use configured server_bundle_output_path if present + if config.server_bundle_output_path.present? + return File.expand_path(File.join(root_path, config.server_bundle_output_path, bundle_name)) + end + end + # Either: # 1. Using same bundle for both server and client, so server bundle will be hashed in manifest # 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) # Default to the non-hashed name in the specified output directory, which, for legacy # React on Rails, this is the output directory picked up by the asset pipeline. # For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file. - File.join(generated_assets_full_path, bundle_name) + File.join(public_bundles_full_path, bundle_name) else begin ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name) rescue Shakapacker::Manifest::MissingEntryError - File.expand_path( - File.join(ReactOnRails::PackerUtils.packer_public_output_path, - bundle_name) - ) + handle_missing_manifest_entry(bundle_name) end end end @@ -165,10 +173,15 @@ def self.using_packer_source_path_is_not_defined_and_custom_node_modules? ReactOnRails.configuration.node_modules_location.present? end - def self.generated_assets_full_path + def self.public_bundles_full_path ReactOnRails::PackerUtils.packer_public_output_path end + # DEPRECATED: Use public_bundles_full_path for clarity about public vs private bundle paths + def self.generated_assets_full_path + public_bundles_full_path + end + def self.gem_available?(name) Gem.loaded_specs[name].present? rescue Gem::LoadError @@ -274,5 +287,29 @@ def self.default_troubleshooting_section • 📖 Discussions: https://github.com/shakacode/react_on_rails/discussions DEFAULT end + + def self.server_bundle?(bundle_name) + bundle_name == ReactOnRails.configuration.server_bundle_js_file || + bundle_name == ReactOnRails.configuration.rsc_bundle_js_file + end + + def self.enforce_private_server_bundles? + ReactOnRails.configuration.enforce_private_server_bundles + end + + def self.handle_missing_manifest_entry(bundle_name) + # For server bundles with enforcement enabled, don't fall back to public paths + if server_bundle?(bundle_name) && enforce_private_server_bundles? + raise Shakapacker::Manifest::MissingEntryError, + "Server bundle '#{bundle_name}' not found in manifest and " \ + "enforce_private_server_bundles is enabled. " \ + "Ensure server bundle is built and server_bundle_output_path is configured correctly." + end + + # Default fallback to non-hashed path in public directory + File.join(public_bundles_full_path, bundle_name) + end + + private_class_method :server_bundle?, :enforce_private_server_bundles?, :handle_missing_manifest_entry end end diff --git a/spec/react_on_rails/configuration_spec.rb b/spec/react_on_rails/configuration_spec.rb index c3037546cf..9b31a00dde 100644 --- a/spec/react_on_rails/configuration_spec.rb +++ b/spec/react_on_rails/configuration_spec.rb @@ -459,6 +459,67 @@ module ReactOnRails end end end + + describe "server bundle security configuration" do + before do + allow(Rails).to receive(:root).and_return(Pathname.new("/app")) + end + + describe ".server_bundle_output_path" do + it "has default value of nil" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + expect(ReactOnRails.configuration.server_bundle_output_path).to be_nil + end + + it "accepts custom path" do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "app/assets/builds" + end + expect(ReactOnRails.configuration.server_bundle_output_path).to eq("app/assets/builds") + end + end + + describe ".enforce_private_server_bundles" do + it "has default value of false" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + expect(ReactOnRails.configuration.enforce_private_server_bundles).to be(false) + end + + it "accepts true value" do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "app/assets/builds" + config.enforce_private_server_bundles = true + end + expect(ReactOnRails.configuration.enforce_private_server_bundles).to be(true) + end + + it "raises error when enabled without server_bundle_output_path" do + expect do + ReactOnRails.configure do |config| + config.enforce_private_server_bundles = true + end + end.to raise_error(ReactOnRails::Error, /server_bundle_output_path is nil/) + end + + it "raises error when server_bundle_output_path is inside public directory" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "public/webpack" + config.enforce_private_server_bundles = true + end + end.to raise_error(ReactOnRails::Error, /is inside the public directory/) + end + + it "allows server_bundle_output_path outside public directory" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "app/assets/builds" + config.enforce_private_server_bundles = true + end + end.not_to raise_error + end + end + end end end