Skip to content

transpiler cache: bind section hashes to input and verify unconditionally - #35101

Closed
robobun wants to merge 3 commits into
mainfrom
farm/c858cc9c/transpiler-cache-hash-check
Closed

transpiler cache: bind section hashes to input and verify unconditionally#35101
robobun wants to merge 3 commits into
mainfrom
farm/c858cc9c/transpiler-cache-hash-check

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What

The runtime transpiler disk cache (~/.bun/install/cache/@t@/*.pile) verified each payload section only against its own self-declared hash in the entry header, and skipped the check entirely when that field was zero. The sourcemap section was never verified at all.

Why

The cache is content-addressed by wyhash(source bytes) alone. Any process that can cause the same source bytes to be transpiled, and that can write the cache directory, can overwrite the entry's output section and zero output_hash; the next reader of that source then executes the replaced output verbatim.

Repro shape
  1. Two files agent/copy.ts and grader/grader.ts contain byte-identical source (≥ 4 KiB).
  2. bun run agent/copy.ts writes a legitimate cache entry keyed by wyhash(source).
  3. Attacker rewrites the .pile file's output section and zeroes output_hash at byte 46.
  4. bun run grader/grader.ts reads the same cache entry, sees output_hash == 0, skips verification, and executes the attacker's JS.

Fix

  • Seed output_hash / sourcemap_hash / esm_record_hash with the entry's input_hash instead of the fixed seed 42, so the stored hash binds each payload to the source bytes that keyed the entry.
  • Verify every section unconditionally on load (removed the != 0 skips; added the missing sourcemap check).
  • Bump EXPECTED_VERSION to 26 so existing entries are discarded.

This is corruption detection, not a security boundary: wyhash is not a MAC and input_hash is derivable from the cache filename. Callers that must not trust a shared cache directory should set BUN_RUNTIME_TRANSPILER_CACHE_PATH=0.

Tests

test/cli/run/transpiler-cache.test.ts gains three cases under rejects tampered entries. Two tamper the output section: one zeroes output_hash, one writes wyhash(42, tampered_output). Both print TAMPERED_OUTPUT_* on 1.4.0 and ORIGINAL_OUTPUT_* with this change. The third overwrites the sourcemap payload and zeroes sourcemap_hash. On 1.4.0 the entry is accepted and the stored hash stays 0. With this change the entry is rejected and rewritten with a real hash.

The existing out-of-range-string-index test is updated to recompute esm_record_hash under the new seed so it still reaches the deserializer it covers.


[review] gate passed · iteration 4 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/run/transpiler-cache.test.ts
bun test v1.4.0 (4199361ed)

test/cli/run/transpiler-cache.test.ts:
(pass) transpiler cache > works [601.46ms]
(pass) transpiler cache > works with empty files [569.88ms]
(pass) transpiler cache > ignores files under the minimum cache size [270.20ms]
(pass) transpiler cache > it is indeed content addressable [862.47ms]
(pass) transpiler cache > doing 50 buns at once does not crash [1512.90ms]
(pass) transpiler cache > disables the cache instead of falling back to the shared temp directory [585.39ms]
(pass) transpiler cache > works if the cache is not user-readable [862.25ms]
(pass) transpiler cache > works if the cache is not user-writable [297.48ms]
242 |       const entryPath = await primeAndLocateEntry("ORIGINAL_OUTPUT_1");
243 |       tamperOutput(entryPath, "ORIGINAL_OUTPUT_1", "TAMPERED_OUTPUT_1", 0n);
244 | 
245 |       // The tampered entry must be rejected; the source is re-transpiled and
246 |       // the original output printed. A fresh entry replaces the rejected one.
247 |       expec
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (062e5543c)

test/cli/run/transpiler-cache.test.ts:
(pass) transpiler cache > works [11.60ms]
(pass) transpiler cache > works with empty files [9.77ms]
(pass) transpiler cache > ignores files under the minimum cache size [4.89ms]
(pass) transpiler cache > it is indeed content addressable [14.48ms]
(pass) transpiler cache > doing 50 buns at once does not crash [21.19ms]
(pass) transpiler cache > disables the cache instead of falling back to the shared temp directory [11.38ms]
(pass) transpiler cache > works if the cache is not user-readable [14.70ms]
(pass) transpiler cache > works if the cache is not user-writable [5.35ms]
(pass) transpiler cache > rejects tampered entries > when output_hash is zeroed [10.81ms]
(pass) transpiler cache > rejects tampered entries > when output_hash is recomputed with the fixed seed [9.45ms]
(pass) transpiler cache > rejects tampered entries > when sourcemap_hash is zeroed [9.57ms]
(pass) transpiler cache > does not inline process.env [10.12ms]
(pass) transpiler cache > --feature flag invalidates cache [29.27ms]
(pass) transpiler cache > a cached entry point does not change how later modules load > require.exte
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/run/transpiler-cache.test.ts
bun test v1.4.0 (4199361ed)

test/cli/run/transpiler-cache.test.ts:
(pass) transpiler cache > works [599.43ms]
(pass) transpiler cache > works with empty files [568.98ms]
(pass) transpiler cache > ignores files under the minimum cache size [272.02ms]
(pass) transpiler cache > it is indeed content addressable [855.75ms]
(pass) transpiler cache > doing 50 buns at once does not crash [1576.15ms]
(pass) transpiler cache > disables the cache instead of falling back to the shared temp directory [575.02ms]
(pass) transpiler cache > works if the cache is not user-readable [856.59ms]
(pass) transpiler cache > works if the cache is not user-writable [290.68ms]
(pass) transpiler cache > rejects tampered entries > when output_hash is zeroed [611.06ms]
(pass) transpiler cache > rejects tampered entries > when output_hash is recomputed with the fixed seed [582.00ms]
(pass) transpiler cache > rejects tampered entries > when sourcemap_hash is zeroed [580.20ms]
(pass) transpiler cache > does not inline process.env 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 568ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/jsc/RuntimeTranspilerCache.rs     | 48 +++++++++---------
 test/cli/run/transpiler-cache.test.ts | 93 +++++++++++++++++++++++++++++++++--
 2 files changed, 113 insertions(+), 28 deletions(-)

gate history · 3 passed · 0 rejected · iteration 4

evidence per changed file
file                                   reads  edits  tests
src/jsc/RuntimeTranspilerCache.rs          3     10      0
test/cli/run/transpiler-cache.test.ts      4      8      0

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 31 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 22 minutes), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 483e1a72-b2c9-43ad-acf1-a9b36a9c7bc5

📥 Commits

Reviewing files that changed from the base of the PR and between 34cbb9a and 2084d7b.

📒 Files selected for processing (2)
  • src/jsc/RuntimeTranspilerCache.rs
  • test/cli/run/transpiler-cache.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:02 AM PT - Aug 20th, 2026

@robobun, your commit 2084d7b is building: #101539

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Security: Fixed-seed non-cryptographic hashes (Wyhash11 / std.hash.Wyhash) are algebraically collidable, enabling trustedDependencies RCE, scoped-registry token leak, and cache poisoning #32741 - This PR directly addresses the cache poisoning vector described in the issue by switching section hashes from a fixed seed (42) to input-keyed hashes and making verification unconditional

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #32741

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Related to #32741 but does not close it. That issue tracks several fixed-seed wyhash uses (trustedDependencies, scoped-registry auth, patch-hash, MySQL statement cache, and the transpiler cache collision in PoC6). This PR only hardens the transpiler cache against the tampered-entry / zeroed-hash path; a true wyhash collision between two distinct sources (PoC6) still maps to the same cache filename and is not addressed here.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 77666: transpiler-cache.test.ts passed on every lane.

The two hard failures are node-parallel GC timing tests that never enter the changed code path (both are under the transpiler cache's 4 KB source floor):

  • test/js/node/test/parallel/test-net-connect-memleak.js (2075 B), also red on main builds 77601 and 77580
  • test/js/node/test/parallel/test-gc-http-client-connaborted.js (1177 B), single-lane timeout on debian 13 x64

The remaining failures are retry-pass flakes (bun-create.test.ts win-aarch64, bun-upgrade.test.ts ETXTBSY, test-http-server-connections-checking-leak.js, test-fs-promises-file-handle-readFile.js). Diff is ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this hardens a security-adjacent path (the on-disk transpiler cache that decides which JS bytes get executed), so a maintainer should sign off on the threat-model framing — particularly the acknowledged limitation that input_hash is derivable from the cache filename, and the relationship to the remaining items in #32741.

What was reviewed:

  • Save/load symmetry: all three encoding arms (UTF8/LATIN1/UTF16), sourcemap, and esm_record now hash with the same input_hash seed on both sides; version bump to 24 invalidates old entries.
  • The reordering in the LATIN1 arm now checks read_bytes before hashing (previously hashed before the short-read check).
  • Confirmed Bun.hash.wyhash in the tests routes to the same bun_wyhash::Wyhash the loader uses, so the updated out-of-range-index test still reaches the deserializer.
  • SEED/hash() remain live (used for input_hash and features_hash), not dead code.
Extended reasoning...

Overview

This PR changes how the runtime transpiler cache (~/.bun/install/cache/@t@/*.pile) validates its payload sections on load. Previously, output_hash / esm_record_hash were computed with a fixed wyhash seed of 42 and verification was skipped whenever the stored hash field was zero; the sourcemap section was never verified at all. The change (a) seeds each section hash with the entry's input_hash so the stored hash binds the payload to the source that keyed the entry, (b) removes the != 0 bypasses so verification is unconditional, (c) adds the missing sourcemap check, and (d) bumps EXPECTED_VERSION to 24 so pre-existing entries (whose hashes were computed under seed 42) are discarded rather than mis-rejected.

Two files touched: src/jsc/RuntimeTranspilerCache.rs (save + load paths) and test/cli/run/transpiler-cache.test.ts (two new tamper-rejection tests, plus the existing out-of-range-string-index test updated to recompute esm_record_hash under the new seed so its corrupted record still reaches the deserializer under test).

Security risks

The transpiler cache is what determines which transpiled JS bytes are handed to JSC for execution, so a bypass in its integrity check is effectively arbitrary code execution against any process that reads a poisoned entry. This PR strictly tightens verification (never loosens it), so the change itself does not introduce new attack surface. The author is explicit that this is corruption detection rather than a security boundary — wyhash is not a MAC, and input_hash (the new seed) is derivable from the cache filename, so an attacker with write access to the cache directory can still forge a passing hash. That framing looks correct to me, but it's exactly the kind of threat-model call a maintainer should ratify, especially given the robobun note that this addresses only one facet of #32741 (the zeroed-hash bypass) and not the wyhash-collision filename case.

Level of scrutiny

Medium-high. The mechanical change is small and easy to verify — swap the seed argument at each Wyhash::hash call site, drop three != 0 guards, add one sourcemap check, bump a version constant. I traced all three output-encoding arms plus the sourcemap and esm_record sections and confirmed save/load are symmetric. The LATIN1 arm's reordering (length check now precedes the hash) is a small correctness improvement. SEED and the free hash() function are still used for input_hash and features_hash, so no dead code was introduced. But because this sits on the boundary between "cached bytes on disk" and "bytes we execute", it warrants a human look rather than a bot approval.

Other factors

Tests are solid: the two new cases exercise both the zeroed-hash and forged-fixed-seed-hash paths and assert the original output is printed (i.e., the entry was rejected and re-transpiled), and the first case additionally asserts a fresh entry with a nonzero hash replaced the rejected one. I confirmed Bun.hash.wyhash (used in the updated existing test to forge a passing esm_record_hash) is backed by the same bun_wyhash::Wyhash implementation the loader uses (src/runtime/api/HashObject.rs:49), so that test will still reach the module-record deserializer it was written to cover. The tamperOutput helper's returned subarray view is used only to compute a hash over already-mutated in-memory bytes before a fresh re-read from disk — no aliasing hazard. CI is still building; no results yet.

robobun added a commit that referenced this pull request Jul 25, 2026
…oot into bun_sys

Section hashes (output / sourcemap / esm_record) are now seeded with
`input_hash` instead of the fixed SEED, so a forged payload whose hash was
recomputed with the constant seed is still rejected. Adopts the approach
from #35101 and carries its test over.

`is_trusted_cache_root` is moved into bun_sys as the single implementation
for both this cache and bunx, and the lstat error arm is tightened so only
ENOENT is trusted (the caller will create the directory); every other
errno fails closed.
@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

A superset of the verification part of this change is in #39717 (header hash, exact layout check against the file size, all section hashes checked, regular file gate). This branch conflicts with main now.

The on-disk runtime transpiler cache verified each payload section
(output, sourcemap, esm_record) only against its own self-declared hash
in the entry header, and skipped the check entirely when that hash was
zero. The sourcemap section was never verified at all.

Because the cache is content-addressed by wyhash of the source bytes
alone, any process that can arrange for the same source bytes to be
transpiled (and can write the cache directory) can overwrite the entry's
output and zero the hash field; the next reader of that source then
executes the replaced output.

Seed output_hash / sourcemap_hash / esm_record_hash with the entry's
input_hash instead of the fixed seed, verify every section
unconditionally on load (including the previously unchecked sourcemap),
and bump the cache version so older entries are discarded.

This does not make the cache a security boundary (wyhash is not a MAC);
callers that must not trust a shared cache should set
BUN_RUNTIME_TRANSPILER_CACHE_PATH=0.
@robobun
robobun force-pushed the farm/c858cc9c/transpiler-cache-hash-check branch from 1db27e2 to fc4e436 Compare August 20, 2026 07:33
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the sourcemap tamper case in 2084d7b — that closes out my earlier coverage note, and no new issues turned up on this pass. I'm leaving this for a human because the author's own note above says this branch now conflicts with main and #39717 carries a superset of the verification logic; someone needs to decide whether to land this, rebase, or fold it into #39717.

Checked: write side (Entry::save) and all three read-side encoding arms use the same Wyhash::hash(input_hash, …) seed; the Latin-1 arm's reordered length-check-before-hash still runs under the errdefer scopeguard so the WTF string is deref'd on InvalidHash; the version bump to 26 discards pre-change entries so no stale-format entry reaches the new checks.

Extended reasoning...

Overview

Two files: src/jsc/RuntimeTranspilerCache.rs (seed section hashes with input_hash instead of the fixed SEED = 42, remove the != 0 skip on all three section-hash verifications, add the previously-missing sourcemap check, bump EXPECTED_VERSION 25 → 26) and test/cli/run/transpiler-cache.test.ts (three new tamper tests plus an update to the existing esm-record corruption test to recompute the hash under the new seed).

Security risks

The transpiler cache controls what bytes get handed to JSC as executable source, so this is security-adjacent by nature. The change strictly tightens verification (unconditional checks, input-bound seed) and the PR is explicit that wyhash is not a MAC and input_hash is derivable from the filename — this is corruption detection, not a trust boundary. I don't see a way for this diff to weaken the existing posture: the version bump ensures no old-seed entry survives, and the only removed conditional was !esm_record.is_empty() on the write side, which is now covered by hashing the empty slice (whose read side is gated on esm_record_byte_length > 0 anyway).

Level of scrutiny

Medium-high. The Rust diff is small and mechanical (same three-line pattern repeated across the UTF-8/Latin-1/UTF-16 output arms plus sourcemap and esm_record), and the scopeguard error-path cleanup is preserved in every arm — I traced the reordered Latin-1 arm specifically and errdefer still fires on the new InvalidHash return. But because a wrong hash comparison here silently discards every cache entry on every run (perf regression) or, worse, accepts a tampered one, and because the author flagged that main has diverged and #39717 already ships a superset (header hash, exact layout check, regular-file gate), a maintainer should decide the merge strategy rather than an automated approval.

Other factors

  • My prior review's only finding (sourcemap check untested) was addressed in 2084d7b; the new test asserts the entry is rewritten with a nonzero sourcemap_hash, which the author confirmed fails when the check is deleted.
  • The hash() free function and SEED constant are still used by get() for input_hash and features_hash, so they're not dead.
  • CI on the earlier build was green for the touched test file across all lanes; the latest commit's build (#101539) status wasn't visible in the timeline.
  • The open question of #39717 vs. this PR is a project-management call, not a code-correctness one.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #39717.

#39717 checks the same three section hashes unconditionally and also adds a header hash, a layout check against the file size, and a regular-file gate. It catches the zeroed-length and flipped-type shapes this PR does not.

The one thing this PR does that #39717 does not is seed the section hashes with input_hash. That adds nothing in practice: input_hash is the cache filename, so anyone who can rewrite the entry can recompute the seeded hash as easily as the fixed-seed one. Both are corruption detection, not a MAC. The grader-side control is still BUN_RUNTIME_TRANSPILER_CACHE_PATH=0.

#35747 covers a different layer (per-uid cache root and ownership check) and is not replaced by either.

@robobun robobun closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants