diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a..e96b5923144 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,8 @@ 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: 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 /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -336,11 +337,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 +431,8 @@ impl Entry { return Err(crate::CrateError::MissingData); } + 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 +475,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 +504,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 +535,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 +553,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 +573,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 01d569bb15a..a637de56594 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -198,6 +198,88 @@ 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; + 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 + // 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("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( join(temp_dir, "a.js"), @@ -310,6 +392,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 +417,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; }