Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
54 changes: 30 additions & 24 deletions src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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"
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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);
Expand All @@ -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);
}
Comment thread
robobun marked this conversation as resolved.

self.sourcemap = sourcemap;
}

if self.metadata.esm_record_byte_length > 0 {
Expand All @@ -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;
Expand Down
74 changes: 70 additions & 4 deletions test/cli/run/transpiler-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Loading