Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4ee772e
Fix server bundle path resolution in test environments (#1797)
justin808 Sep 21, 2025
9e1465a
Improve bundle path resolution with secure server bundle locations
justin808 Sep 21, 2025
7aff6da
Apply suggestions from code review
justin808 Sep 21, 2025
b0c4963
Update Gemfile.lock and CHANGELOG.md
justin808 Sep 21, 2025
61a1254
Add configuration options for secure server bundle management
justin808 Sep 21, 2025
d974d82
Fix breaking change: Set server_bundle_output_path default to nil
justin808 Sep 22, 2025
60b50d8
Fix CI failures by restoring packer availability check
justin808 Sep 22, 2025
6a2035f
Clean solution: Remove redundant using_packer? check with proper test…
justin808 Sep 22, 2025
afd06ed
Implement enforce_secure_server_bundles with comprehensive improvements
justin808 Sep 22, 2025
ed37a2b
refactor: Use configuration-based approach for server bundle output path
github-actions[bot] Sep 22, 2025
fa07c57
refactor: Use configuration-based approach for server bundle output path
github-actions[bot] Sep 22, 2025
55a30c0
Fix CI failures: RuboCop violations and test failures
github-actions[bot] Sep 22, 2025
8936a77
Fix dummy app configuration for test environment
github-actions[bot] Sep 22, 2025
88212b8
refactor: Return configured server_bundle_output_path directly withou…
github-actions[bot] Sep 22, 2025
d917bda
Fix CI failures: RuboCop violations and test failures
github-actions[bot] Sep 22, 2025
b478d61
refactor: Rename 'secure' to 'private' for server bundles terminology
github-actions[bot] Sep 22, 2025
ac7c87b
Fix server bundle path resolution in test environments (#1797)
justin808 Sep 21, 2025
8120cd8
Fix RuboCop violations and update documentation
justin808 Sep 23, 2025
52bf049
Restore server bundle support and fix failing tests
justin808 Sep 23, 2025
c5c8239
Improve backwards compatibility by changing server_bundle_output_path…
justin808 Sep 23, 2025
e9dcf16
Fix NoMethodError in non-packer environments by conditionally buildin…
justin808 Sep 23, 2025
6db4375
Implement enforce_private_server_bundles security feature and add com…
justin808 Sep 23, 2025
5b999f9
Fix configuration tests by mocking PackerUtils.using_packer?
justin808 Sep 23, 2025
e7db6de
Clarify method naming: rename generated_assets_full_path → public_ass…
justin808 Sep 23, 2025
f862aa7
Improve method naming: use public_bundles_full_path instead of public…
justin808 Sep 23, 2025
8d419c9
Enhance documentation: crystal clear changelog and configuration guide
justin808 Sep 23, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@ yalc.lock
# Generated by ROR FS-based Registry
generated

# Server-side rendering generated bundles
ssr-generated

# Claude Code local settings
.claude/settings.local.json
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,36 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th

Changes since the last non-beta release.

#### New Features

- **Server Bundle Security**: Added new configuration options for enhanced server bundle security and organization:
- `server_bundle_output_path`: Configurable directory for server bundle output (default: nil, uses fallback locations)
- `enforce_private_server_bundles`: When enabled, ensures server bundles are only loaded from private directories outside the public folder (default: false for backward compatibility)

- **Improved Bundle Path Resolution**: Enhanced bundle path resolution with better fallback logic that tries multiple locations when manifest lookup fails:
1. Environment-specific path (e.g., `public/webpack/test`)
2. Standard Shakapacker location (`public/packs`)
3. Generated assets path (for legacy setups)

#### API Improvements

- **Method Naming Clarification**: Added `public_bundles_full_path` method to clarify bundle path handling:
- `public_bundles_full_path`: New method specifically for webpack bundles in public directories
- `generated_assets_full_path`: Now deprecated (backwards-compatible alias)
- This eliminates confusion between webpack bundles and general Rails public assets

#### Security Enhancements

- **Private Server Bundle Enforcement**: When `enforce_private_server_bundles` is enabled, server bundles bypass public directory fallbacks and are only loaded from designated private locations
- **Path Validation**: Added validation to ensure `server_bundle_output_path` points to private directories when enforcement is enabled

#### Bug Fixes

- **Non-Packer Environment Compatibility**: Fixed potential NoMethodError when using bundle path resolution in environments without Shakapacker
- **Server Bundle Detection**: Improved server bundle detection to work correctly with both `server_bundle_js_file` and `rsc_bundle_js_file` configurations

### [16.0.1-rc.2] - 2025-09-20

#### Bug Fixes

- **Packs generator**: Fixed error when `server_bundle_js_file` configuration is empty (default). Added safety check to prevent attempting operations on invalid file paths when server-side rendering is not configured. [PR 1802](https://github.com/shakacode/react_on_rails/pull/1802)
Expand Down
63 changes: 63 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,69 @@ ReactOnRails.configure do |config|
# This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename.
config.react_server_client_manifest_file = "react-server-client-manifest.json"

################################################################################
# SERVER BUNDLE SECURITY AND ORGANIZATION
################################################################################

# Directory where server bundles will be output during build process.
# This allows organizing server-side rendering assets separately from client assets.
#
# Default is nil, which uses standard fallback locations in this priority order:
# 1. Environment-specific path (e.g., public/webpack/test)
# 2. Standard Shakapacker location (public/packs)
# 3. Generated assets path (for legacy setups)
#
# Example configurations:
# config.server_bundle_output_path = "ssr-generated" # Private directory (recommended)
# config.server_bundle_output_path = "app/assets/builds" # Custom private location
# config.server_bundle_output_path = nil # Use fallback locations (default)
config.server_bundle_output_path = nil

# When enabled, enforces that server bundles are only loaded from private, designated locations
# to prevent potential security risks from loading untrusted server-side code.
#
# SECURITY IMPORTANT: When enabled, server bundles bypass public directory fallbacks
# and are only loaded from the configured server_bundle_output_path.
#
# Requirements when enabled:
# - server_bundle_output_path must be set to a non-nil value
# - server_bundle_output_path must point to a location outside the public directory
# - Recommended for production environments where security is critical
#
# Default is false for backward compatibility.
config.enforce_private_server_bundles = false

################################################################################
# BUNDLE ORGANIZATION EXAMPLES
################################################################################
#
# This configuration creates a clear separation between client and server assets:
#
# CLIENT BUNDLES (Public, Web-Accessible):
# Location: public/webpack/[environment]/ or public/packs/
# Files: application.js, manifest.json, CSS files
# Served by: Web server directly
# Access: ReactOnRails::Utils.public_bundles_full_path
#
# SERVER BUNDLES (Private, Server-Only):
# Location: ssr-generated/ (when server_bundle_output_path configured)
# Files: server-bundle.js, rsc-bundle.js
# Served by: Never served to browsers
# Access: ReactOnRails::Utils.server_bundle_js_file_path
#
# Example directory structure with recommended configuration:
# app/
# ├── ssr-generated/ # Private server bundles
# │ ├── server-bundle.js
# │ └── rsc-bundle.js
# └── public/
# └── webpack/development/ # Public client bundles
# ├── application.js
# ├── manifest.json
# └── styles.css
#
################################################################################

# `prerender` means server-side rendering
# default is false. This is an option for view helpers `render_component` and `render_component_hash`.
# Set to true to change the default value to true.
Expand Down
15 changes: 9 additions & 6 deletions lib/generators/react_on_rails/base_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,17 @@ def update_gitignore_for_auto_registration
return unless File.exist?(gitignore_path)

gitignore_content = File.read(gitignore_path)
return if gitignore_content.include?("**/generated/**")

append_to_file ".gitignore" do
<<~GITIGNORE
additions = []
additions << "**/generated/**" unless gitignore_content.include?("**/generated/**")
additions << "ssr-generated" unless gitignore_content.include?("ssr-generated")

return if additions.empty?

# Generated React on Rails packs
**/generated/**
GITIGNORE
append_to_file ".gitignore" do
lines = ["\n# Generated React on Rails packs"]
lines.concat(additions)
"#{lines.join("\n")}\n"
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ ReactOnRails.configure do |config|
#
config.server_bundle_js_file = "server-bundle.js"

# Configure where server bundles are output. Defaults to "ssr-generated".
# This should match your webpack configuration for server bundles.
config.server_bundle_output_path = "ssr-generated"

# Enforce that server bundles are only loaded from private (non-public) directories.
# When true, server bundles will only be loaded from the configured server_bundle_output_path.
# This is recommended for production to prevent server-side code from being exposed.
config.enforce_private_server_bundles = true

################################################################################
################################################################################
# FILE SYSTEM BASED COMPONENT REGISTRY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,14 @@ const configureServer = () => {

// Custom output for the server-bundle that matches the config in
// config/initializers/react_on_rails.rb
// Server bundles are output to a private directory (not public) for security
serverWebpackConfig.output = {
filename: 'server-bundle.js',
globalObject: 'this',
// If using the React on Rails Pro node server renderer, uncomment the next line
// libraryTarget: 'commonjs2',
path: config.outputPath,
publicPath: config.publicPath,
path: require('path').resolve(__dirname, '../../ssr-generated'),
// No publicPath needed since server bundles are not served via web
// https://webpack.js.org/configuration/output/#outputglobalobject
};

Expand Down
36 changes: 33 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ 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_output_path: nil,
enforce_private_server_bundles: false
)
end

Expand All @@ -68,7 +70,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,
Expand All @@ -85,7 +88,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
Expand Down Expand Up @@ -130,6 +133,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

Expand All @@ -146,6 +151,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
Expand Down Expand Up @@ -194,6 +200,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?
Expand Down
2 changes: 1 addition & 1 deletion lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def generated_server_pack_file_content

def add_generated_pack_to_server_bundle
return if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint
return if ReactOnRails.configuration.server_bundle_js_file.empty?
return if ReactOnRails.configuration.server_bundle_js_file.blank?

relative_path_to_generated_server_bundle = relative_path(server_bundle_entrypoint,
generated_server_bundle_file_path)
Expand Down
89 changes: 76 additions & 13 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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
Expand All @@ -81,21 +92,68 @@ def self.bundle_js_file_path(bundle_name)
# a. The webpack manifest plugin would have a race condition where the same manifest.json
# is edited by both the webpack-dev-server
# b. There is no good reason to hash the server bundle name.
if bundle_name == "manifest.json"
# 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)
else
if ReactOnRails::PackerUtils.using_packer? && bundle_name != "manifest.json"
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)
)
rescue Object.const_get(
ReactOnRails::PackerUtils.packer_type.capitalize
)::Manifest::MissingEntryError
handle_missing_manifest_entry(bundle_name)
end
else
# 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(public_bundles_full_path, bundle_name)
end
end

private_class_method def self.handle_missing_manifest_entry(bundle_name)
# For server bundles with enforcement enabled, skip public path fallbacks
return server_bundle_private_path(bundle_name) if server_bundle?(bundle_name) && enforce_private_server_bundles?

# When manifest lookup fails, try multiple fallback locations:
# Build fallback locations conditionally based on packer availability
fallback_locations = []

# 1. Environment-specific path (e.g., public/webpack/test) - only if using packer
if ReactOnRails::PackerUtils.using_packer?
fallback_locations << File.join(ReactOnRails::PackerUtils.packer_public_output_path, bundle_name)
end

# 2. Standard Shakapacker location (public/packs)
fallback_locations << File.join("public", "packs", bundle_name)

# 3. Generated assets path (for legacy setups)
fallback_locations << File.join(public_bundles_full_path, bundle_name)

fallback_locations.uniq!

# Return the first location where the bundle file actually exists
fallback_locations.each do |path|
expanded_path = File.expand_path(path)
return expanded_path if File.exist?(expanded_path)
end

# If none exist, return the environment-specific path (original behavior)
File.expand_path(fallback_locations.first)
end

private_class_method def self.server_bundle?(bundle_name)
config = ReactOnRails.configuration
bundle_name == config.server_bundle_js_file ||
bundle_name == config.rsc_bundle_js_file
end

private_class_method def self.enforce_private_server_bundles?
ReactOnRails.configuration.enforce_private_server_bundles
end

private_class_method def self.server_bundle_private_path(bundle_name)
config = ReactOnRails.configuration
preferred_dir = config.server_bundle_output_path.presence || "ssr-generated"
root_path = Rails.root || "."
File.expand_path(File.join(root_path, preferred_dir, bundle_name))
end

def self.server_bundle_js_file_path
Expand Down Expand Up @@ -130,7 +188,7 @@ def self.react_server_client_manifest_file_path
"react_server_client_manifest_file is nil, ensure it is set in your configuration"
end

@react_server_manifest_path = File.join(generated_assets_full_path, asset_name)
@react_server_manifest_path = File.join(public_bundles_full_path, asset_name)
end

def self.running_on_windows?
Expand Down Expand Up @@ -166,10 +224,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
Expand Down
5 changes: 5 additions & 0 deletions spec/dummy/config/initializers/react_on_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ def self.adjust_props_for_client_side_hydration(component_name, props)
config.server_bundle_js_file = "server-bundle.js"
config.random_dom_id = false # default is true

# Set server_bundle_output_path to nil so bundles are read from public path
# This ensures the dummy app uses the standard webpack output location
config.server_bundle_output_path = nil
config.enforce_private_server_bundles = false

# Uncomment to test these
# config.build_test_command = "yarn run build:test"
# config.build_production_command = "RAILS_ENV=production NODE_ENV=production bin/shakapacker"
Expand Down
Loading
Loading