Skip to content

Commit fb3de1b

Browse files
justin808claude
andcommitted
fix: address remaining PR C review feedback (security, robustness, docs)
Post-revision review surfaced 7 new actionable items. All fixed. Code: 1. PREVIOUS_BUNDLE_HASHES path-traversal (claude): env values are used as directory names under the renderer cache; a malicious or mistyped value like "../../../etc" would escape the cache dir. Now reject anything that doesn't match /\A[A-Za-z0-9_\-.]+\z/ with a warning. 2. node_renderer? guard in publish_current_bundle_if_configured (claude): returns early when ExecJS is configured rather than crashing on NodeRenderingPool.server_bundle_hash. Mirrors the guard used by PreSeedRendererCache. 5. Top-level rescue in publish_current_bundle_if_configured (cursor Medium): collect_assets / server_bundle_hash / rsc_bundle_js_file_path can all raise from the setup code that ran before the per-upload rescue. Extracted publish_bundles helper and wrapped the whole publication path with StandardError rescue so a failure degrades next-deploy seeding but doesn't fail this deploy's assets:precompile. 7. Partial-staging cleanup on asset failure (codex P1): previously if stage_file succeeded on the bundle but failed on an asset, the cache held a bundle file without its companion manifests. The renderer would then find the bundle and serve without those manifests, causing hydration breakage instead of the clean 410 retry fallback. Now rm_rf the hash directory on any failure inside seed_previous_hash — restores the 410-retry safety net. Docs: 3. Control Plane fetch example: replaced Dir[tmp/*.json] with an explicit allowlist so stray JSON metadata in the image layer isn't silently picked up. 4. S3 client mutex: @s3 ||= is not thread-safe under concurrent callers (parallel workers running precompile hooks). Example now uses a Mutex-synchronized memoizer with a doc comment. 6. S3 manifest RETENTION alignment: write and read both trim to RETENTION now, so the persisted manifest matches what discovery returns. Added a comment explaining the intent. Tests: 40 rolling-deploy + pre-seed + shim specs (was 38, +2 for path-traversal reject and partial-staging cleanup). Rubocop clean. Refs: #3122, #3173 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e9b3f8 commit fb3de1b

4 files changed

Lines changed: 88 additions & 4 deletions

File tree

docs/pro/rolling-deploy-adapters.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,14 @@ class S3RollingDeployAdapter
158158

159159
# -- helpers (private by convention) --
160160

161+
S3_CLIENT_MUTEX = Mutex.new
162+
private_constant :S3_CLIENT_MUTEX
163+
164+
# Thread-safe memoization. Without the mutex, concurrent callers (e.g. parallel
165+
# Sidekiq workers running precompile hooks) could each create a separate client;
166+
# only the last survives, and the earlier ones are silently discarded after use.
161167
def self.s3
162-
@s3 ||= Aws::S3::Client.new
168+
S3_CLIENT_MUTEX.synchronize { @s3 ||= Aws::S3::Client.new }
163169
end
164170

165171
def self.download_to(dir, name, hash)
@@ -181,10 +187,13 @@ class S3RollingDeployAdapter
181187
def self.update_manifest!(hash)
182188
hashes = previous_bundle_hashes
183189
hashes << hash unless hashes.include?(hash)
190+
# Write and read both trim to RETENTION so the persisted manifest matches
191+
# what discovery returns. If you want a soft buffer of historical entries,
192+
# increase RETENTION rather than diverging these two trim lengths.
184193
s3.put_object(
185194
bucket: bucket,
186195
key: MANIFEST_KEY,
187-
body: JSON.generate(hashes: hashes.last(RETENTION + 2))
196+
body: JSON.generate(hashes: hashes.last(RETENTION))
188197
)
189198
end
190199
end
@@ -233,7 +242,12 @@ class ControlPlaneRollingDeployAdapter
233242
bundle = Dir[File.join(tmp, "*.js")].first
234243
return nil unless bundle
235244

236-
{ bundle: bundle, assets: Dir[File.join(tmp, "*.json")] }
245+
# Explicit allowlist — don't sweep up unrelated JSON files that may be
246+
# bundled in the image layer (lock files, health-check payloads, etc.).
247+
asset_names = %w[loadable-stats.json react-client-manifest.json react-server-client-manifest.json]
248+
assets = asset_names.map { |name| File.join(tmp, name) }.select { |path| File.exist?(path) }
249+
250+
{ bundle: bundle, assets: assets }
237251
end
238252

239253
def self.upload(_hash, bundle:, assets:)

react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,22 @@ def self.call
8383
def self.publish_current_bundle_if_configured
8484
adapter = ReactOnRailsPro.configuration.rolling_deploy_adapter
8585
return if adapter.nil?
86+
# NodeRendererPool.server_bundle_hash is only available under the NodeRenderer
87+
# renderer mode. With ExecJS, skip publication rather than crash.
88+
return unless ReactOnRailsPro.configuration.node_renderer?
8689
return if Rails.env.development? || Rails.env.test?
8790

91+
publish_bundles(adapter)
92+
rescue StandardError => e
93+
# Outer rescue catches anything raised by the setup-side calls below
94+
# (collect_assets, server_bundle_hash, rsc_bundle_js_file_path). Per the
95+
# rolling-deploy contract, a failed upload must degrade the next deploy's
96+
# seeding — not fail *this* deploy's assets:precompile.
97+
warn "[ReactOnRailsPro] rolling_deploy_adapter publication failed: #{e.class}: #{e.message}. " \
98+
"Next deploy's rolling-deploy seeding may degrade; precompile continuing."
99+
end
100+
101+
def self.publish_bundles(adapter)
88102
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
89103
assets = ReactOnRailsPro::RendererCacheHelpers.collect_assets.map(&:to_s)
90104

react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,21 @@ def self.handle_missing_adapter
5858
end
5959
private_class_method :handle_missing_adapter
6060

61+
# Bundle hashes are used as directory names under the renderer cache path
62+
# (<cache>/<hash>/<hash>.js). Values coming from config.rolling_deploy_adapter
63+
# are trusted, but PREVIOUS_BUNDLE_HASHES comes from the environment and
64+
# could contain path separators (e.g. "../../etc") that would escape the
65+
# cache directory. Reject anything that isn't a safe hash slug.
66+
SAFE_HASH_PATTERN = /\A[A-Za-z0-9_\-.]+\z/
67+
6168
def self.resolve_previous_hashes(adapter, current_hashes)
6269
explicit = ENV["PREVIOUS_BUNDLE_HASHES"].to_s.split(",").map(&:strip).reject(&:empty?)
70+
invalid = explicit.grep_v(SAFE_HASH_PATTERN)
71+
if invalid.any?
72+
warn "[ReactOnRailsPro] PREVIOUS_BUNDLE_HASHES contains invalid hash values (rejected): " \
73+
"#{invalid.inspect}. Hashes must match /#{SAFE_HASH_PATTERN.source}/ to prevent path traversal."
74+
explicit -= invalid
75+
end
6376
hashes = explicit.any? ? explicit : fetch_hashes_from_adapter(adapter)
6477
# Deduplicate against the hashes we just staged so we don't re-fetch the current build.
6578
hashes - Array(current_hashes).map(&:to_s)
@@ -86,8 +99,14 @@ def self.seed_previous_hash(adapter, hash, cache_dir, mode)
8699
stage_file(asset_path, File.join(bundle_dir, File.basename(asset_path)), mode)
87100
end
88101
rescue StandardError => e
102+
# Roll back the entire hash directory. Leaving the bundle file in place
103+
# without its companion assets would cause the renderer to find the bundle
104+
# (skipping its 410 path) and emit HTML referencing chunks from a manifest
105+
# that never got staged — producing hydration failures instead of the clean
106+
# 410-retry fallback that we rely on for degradation.
107+
FileUtils.rm_rf(File.join(cache_dir, hash))
89108
warn "[ReactOnRailsPro] Failed to seed previous bundle hash #{hash}: #{e.class}: #{e.message}. " \
90-
"Runtime 410-retry path remains available as fallback."
109+
"Rolled back partially-staged files. Runtime 410-retry remains the fallback."
91110
end
92111
private_class_method :seed_previous_hash
93112

react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,43 @@ def source_file(name, contents: "// #{name}")
146146
end
147147
end
148148

149+
context "when PREVIOUS_BUNDLE_HASHES contains an unsafe path-traversal value" do
150+
let(:src_bundle) { source_file("bundle-ok.js") }
151+
152+
before do
153+
ENV["PREVIOUS_BUNDLE_HASHES"] = "../../../etc,safe-hash"
154+
allow(adapter).to receive(:previous_bundle_hashes)
155+
allow(adapter).to receive(:fetch).with("safe-hash").and_return(bundle: src_bundle, assets: [])
156+
end
157+
158+
it "rejects the unsafe value with a warning and stages only the safe one" do
159+
expect { described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy) }
160+
.to output(/invalid hash values \(rejected\).*etc/m).to_stderr
161+
162+
expect(adapter).not_to have_received(:fetch).with("../../../etc")
163+
expect(File.exist?(File.join(cache_dir, "safe-hash", "safe-hash.js"))).to be(true)
164+
end
165+
end
166+
167+
context "when an asset stage fails mid-way" do
168+
let(:src_bundle) { source_file("bundle-partial.js") }
169+
170+
before do
171+
allow(adapter).to receive_messages(previous_bundle_hashes: ["abc123"])
172+
allow(adapter).to receive(:fetch).with("abc123").and_return(
173+
bundle: src_bundle,
174+
assets: ["/nonexistent/loadable-stats.json"]
175+
)
176+
end
177+
178+
it "rolls back the entire hash directory so the renderer sees 410, not a bundle without manifests" do
179+
described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy)
180+
181+
bundle_dir = File.join(cache_dir, "abc123")
182+
expect(File.exist?(bundle_dir)).to be(false)
183+
end
184+
end
185+
149186
context "when adapter returns payload without :bundle" do
150187
before do
151188
allow(adapter).to receive_messages(previous_bundle_hashes: ["bad-hash"])

0 commit comments

Comments
 (0)