From fc4e4360ad78d494bc93b80693dcdbf146c23713 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:50:16 +0000 Subject: [PATCH 1/3] transpiler cache: bind section hashes to input, verify unconditionally 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. --- src/jsc/RuntimeTranspilerCache.rs | 54 ++++++++++--------- test/cli/run/transpiler-cache.test.ts | 74 +++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 28 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a6..381fb6ca2e43 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,11 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -const EXPECTED_VERSION: u32 = 25; +/// Version 26: `output_hash` / `sourcemap_hash` / `esm_record_hash` are seeded +/// with `input_hash` instead of the fixed `SEED`, and a stored hash of 0 no +/// longer skips verification. Entries written before this version have section +/// hashes that will not match under the new seed. +const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -336,11 +340,9 @@ impl Entry { ..Default::default() }; - metadata.output_hash = hash(output_bytes); - metadata.sourcemap_hash = hash(sourcemap); - if !esm_record.is_empty() { - metadata.esm_record_hash = hash(esm_record); - } + metadata.output_hash = Wyhash::hash(input_hash, output_bytes); + metadata.sourcemap_hash = Wyhash::hash(input_hash, sourcemap); + metadata.esm_record_hash = Wyhash::hash(input_hash, esm_record); let mut metadata_stream = bun_io::FixedBufferStream::new_mut(&mut metadata_buf[..]); metadata.encode(&mut metadata_stream)?; @@ -432,6 +434,11 @@ impl Entry { return Err(crate::CrateError::MissingData); } + // Section hashes are keyed on the input hash so the stored value binds + // each payload to the source bytes that produced this entry. The caller + // has already verified `metadata.input_hash` against the live source. + let section_seed = self.metadata.input_hash; + debug_assert!( matches!(&self.output_code, OutputCode::Utf8(b) if b.is_empty()), "this should be the default value" @@ -474,7 +481,7 @@ impl Entry { return Err(crate::CrateError::MissingData); } - if self.metadata.output_hash != 0 && hash(bytes) != self.metadata.output_hash { + if Wyhash::hash(section_seed, bytes) != self.metadata.output_hash { return Err(crate::CrateError::InvalidHash); } @@ -503,17 +510,14 @@ impl Entry { // errdefer latin1.deref() — BunString is `Copy`, so guard explicitly. let errdefer = scopeguard::guard(latin1, |s| s.deref()); let read_bytes = file.pread_all(bytes, self.metadata.output_byte_offset)?; - - if self.metadata.output_hash != 0 { - if hash(latin1.latin1()) != self.metadata.output_hash { - return Err(crate::CrateError::InvalidHash); - } - } - if read_bytes as u64 != self.metadata.output_byte_length { return Err(crate::CrateError::MissingData); } + if Wyhash::hash(section_seed, latin1.latin1()) != self.metadata.output_hash { + return Err(crate::CrateError::InvalidHash); + } + scopeguard::ScopeGuard::into_inner(errdefer); OutputCode::String(latin1) } @@ -537,11 +541,9 @@ impl Entry { return Err(crate::CrateError::MissingData); } - if self.metadata.output_hash != 0 { - let utf16_bytes: &[u8] = bytemuck::cast_slice(string.utf16()); - if hash(utf16_bytes) != self.metadata.output_hash { - return Err(crate::CrateError::InvalidHash); - } + let utf16_bytes: &[u8] = bytemuck::cast_slice(string.utf16()); + if Wyhash::hash(section_seed, utf16_bytes) != self.metadata.output_hash { + return Err(crate::CrateError::InvalidHash); } scopeguard::ScopeGuard::into_inner(errdefer); @@ -557,11 +559,17 @@ impl Entry { let output_code_errdefer = scopeguard::guard(&mut self.output_code, |oc| oc.deinit()); if self.metadata.sourcemap_byte_length > 0 { - self.sourcemap = pread_box( + let sourcemap = pread_box( file, self.metadata.sourcemap_byte_length as usize, self.metadata.sourcemap_byte_offset, )?; + + if Wyhash::hash(section_seed, &sourcemap) != self.metadata.sourcemap_hash { + return Err(crate::CrateError::InvalidHash); + } + + self.sourcemap = sourcemap; } if self.metadata.esm_record_byte_length > 0 { @@ -571,10 +579,8 @@ impl Entry { self.metadata.esm_record_byte_offset, )?; - if self.metadata.esm_record_hash != 0 { - if hash(&esm_record) != self.metadata.esm_record_hash { - return Err(crate::CrateError::InvalidHash); - } + if Wyhash::hash(section_seed, &esm_record) != self.metadata.esm_record_hash { + return Err(crate::CrateError::InvalidHash); } self.esm_record = esm_record; diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 01d569bb15ab..99bb27cc0fd9 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -198,6 +198,69 @@ describe("transpiler cache", () => { chmodSync(join(cache_dir), "777"); } }); + describe("rejects tampered entries", () => { + // Metadata layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): + // 0:u32 version, 4:u8 module_type, 5:u8 encoding, 6:u64 features_hash, + // 14:u64 input_byte_length, 22:u64 input_hash, + // 30:u64 output_byte_offset, 38:u64 output_byte_length, 46:u64 output_hash, + // 54:u64 sourcemap_byte_offset, 62:u64 sourcemap_byte_length, 70:u64 sourcemap_hash, + // 78:u64 esm_record_byte_offset, 86:u64 esm_record_byte_length, 94:u64 esm_record_hash, + // 102: payload. + const OUTPUT_BYTE_OFFSET_AT = 30; + const OUTPUT_BYTE_LENGTH_AT = 38; + const OUTPUT_HASH_AT = 46; + + async function primeAndLocateEntry(marker: string) { + // >= MINIMUM_CACHE_SIZE so the source is cached, and the marker string + // is printed verbatim so it appears as a literal in the transpiled + // output section. + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "tamper", { code: JSON.stringify(marker) })); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn(marker); + const entries = readdirSync(cache_dir); + expect(entries.length).toBe(1); + return join(cache_dir, entries[0]); + } + + function tamperOutput(entryPath: string, marker: string, replacement: string, outputHash: bigint) { + expect(replacement.length).toBe(marker.length); + const data = readFileSync(entryPath); + const outOff = Number(data.readBigUInt64LE(OUTPUT_BYTE_OFFSET_AT)); + const outLen = Number(data.readBigUInt64LE(OUTPUT_BYTE_LENGTH_AT)); + const output = data.subarray(outOff, outOff + outLen); + const idx = output.indexOf(marker); + expect(idx).toBeGreaterThanOrEqual(0); + output.write(replacement, idx, "utf-8"); + data.writeBigUInt64LE(outputHash, OUTPUT_HASH_AT); + writeFileSync(entryPath, data); + return output; + } + + test("when output_hash is zeroed", async () => { + const entryPath = await primeAndLocateEntry("ORIGINAL_OUTPUT_1"); + tamperOutput(entryPath, "ORIGINAL_OUTPUT_1", "TAMPERED_OUTPUT_1", 0n); + + // The tampered entry must be rejected; the source is re-transpiled and + // the original output printed. A fresh entry replaces the rejected one. + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ORIGINAL_OUTPUT_1"); + expect(readdirSync(cache_dir).length).toBe(1); + const rewritten = readFileSync(entryPath); + expect(rewritten.readBigUInt64LE(OUTPUT_HASH_AT)).not.toBe(0n); + }); + + test("when output_hash is recomputed with the fixed seed", async () => { + const entryPath = await primeAndLocateEntry("ORIGINAL_OUTPUT_2"); + // Section hashes are keyed on the per-entry input hash, not a fixed + // seed, so a hash derived from the tampered bytes alone is still + // rejected. + const tampered = tamperOutput(entryPath, "ORIGINAL_OUTPUT_2", "TAMPERED_OUTPUT_2", 0n); + const forged = Bun.hash.wyhash(tampered, 42n); + const data = readFileSync(entryPath); + data.writeBigUInt64LE(forged, OUTPUT_HASH_AT); + writeFileSync(entryPath, data); + + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ORIGINAL_OUTPUT_2"); + }); + }); test("does not inline process.env", async () => { writeFileSync( join(temp_dir, "a.js"), @@ -310,6 +373,7 @@ test("rejects cached module records containing out-of-range string indices", () // serialize()): // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] // [buffer_len u32][buffer: u32 string index x buffer_len] ... + const INPUT_HASH_AT = 22; const ESM_RECORD_BYTE_OFFSET_AT = 78; const ESM_RECORD_BYTE_LENGTH_AT = 86; const ESM_RECORD_HASH_AT = 94; @@ -334,10 +398,12 @@ test("rejects cached module records containing out-of-range string indices", () for (let i = 0; i < bufferLen; i++) { data.writeUInt32LE(0x7fffffff, off + i * 4); } - // The cache loader skips esm-record content verification when the stored - // hash field is zero, so whoever writes the cache file controls exactly - // what reaches the module record deserializer. - data.writeBigUInt64LE(0n, ESM_RECORD_HASH_AT); + // Section hashes are keyed on the input hash; recompute it for the + // rewritten record so the entry passes the loader's hash check and the + // corrupted indices reach the module-record deserializer under test. + const inputHash = data.readBigUInt64LE(INPUT_HASH_AT); + const esmHash = Bun.hash.wyhash(data.subarray(esmOff, esmOff + esmLen), inputHash); + data.writeBigUInt64LE(esmHash, ESM_RECORD_HASH_AT); writeFileSync(file, data); return true; } From 062e5543c2bc8e1ce83dc90ea5788a464d366f9e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:34:59 +0000 Subject: [PATCH 2/3] transpiler cache: trim comments --- src/jsc/RuntimeTranspilerCache.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 381fb6ca2e43..e96b5923144f 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,10 +51,7 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -/// Version 26: `output_hash` / `sourcemap_hash` / `esm_record_hash` are seeded -/// with `input_hash` instead of the fixed `SEED`, and a stored hash of 0 no -/// longer skips verification. Entries written before this version have section -/// hashes that will not match under the new seed. +/// Version 26: Section hashes are seeded with `input_hash` and always verified. const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk @@ -434,9 +431,6 @@ impl Entry { return Err(crate::CrateError::MissingData); } - // Section hashes are keyed on the input hash so the stored value binds - // each payload to the source bytes that produced this entry. The caller - // has already verified `metadata.input_hash` against the live source. let section_seed = self.metadata.input_hash; debug_assert!( From 2084d7b5276bb2656385b983dfd1ea7dd8e9defe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:01:56 +0000 Subject: [PATCH 3/3] transpiler cache: cover sourcemap hash rejection --- test/cli/run/transpiler-cache.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 99bb27cc0fd9..a637de565942 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -209,6 +209,9 @@ describe("transpiler cache", () => { const OUTPUT_BYTE_OFFSET_AT = 30; const OUTPUT_BYTE_LENGTH_AT = 38; const OUTPUT_HASH_AT = 46; + const SOURCEMAP_BYTE_OFFSET_AT = 54; + const SOURCEMAP_BYTE_LENGTH_AT = 62; + const SOURCEMAP_HASH_AT = 70; async function primeAndLocateEntry(marker: string) { // >= MINIMUM_CACHE_SIZE so the source is cached, and the marker string @@ -260,6 +263,22 @@ describe("transpiler cache", () => { expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ORIGINAL_OUTPUT_2"); }); + + test("when sourcemap_hash is zeroed", async () => { + const entryPath = await primeAndLocateEntry("ORIGINAL_OUTPUT_3"); + const data = readFileSync(entryPath); + const smOff = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_OFFSET_AT)); + const smLen = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_LENGTH_AT)); + expect(smLen).toBeGreaterThan(0); + data.fill(0xff, smOff, smOff + smLen); + data.writeBigUInt64LE(0n, SOURCEMAP_HASH_AT); + writeFileSync(entryPath, data); + + // A tampered sourcemap does not change stdout, so rejection is observed + // through the entry being deleted and rewritten with a real hash. + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ORIGINAL_OUTPUT_3"); + expect(readFileSync(entryPath).readBigUInt64LE(SOURCEMAP_HASH_AT)).not.toBe(0n); + }); }); test("does not inline process.env", async () => { writeFileSync(