diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a..6a4ef2135b0 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,9 @@ 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` (not the fixed +/// `SEED`) and a stored hash of 0 no longer skips verification. +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 +338,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 +432,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 +476,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 +505,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 +536,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); @@ -562,6 +559,9 @@ impl Entry { self.metadata.sourcemap_byte_length as usize, self.metadata.sourcemap_byte_offset, )?; + if Wyhash::hash(section_seed, &self.sourcemap) != self.metadata.sourcemap_hash { + return Err(crate::CrateError::InvalidHash); + } } if self.metadata.esm_record_byte_length > 0 { @@ -571,10 +571,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; @@ -613,6 +611,55 @@ pub(crate) fn hash(bytes: &[u8]) -> u64 { Wyhash::hash(SEED, bytes) } +/// Effective uid: the identity the kernel checks file access against. +#[cfg(unix)] +#[inline] +fn current_user_id() -> u32 { + bun_sys::c::geteuid() as u32 +} + +#[cfg(windows)] +#[inline] +fn current_user_id() -> u32 { + bun_sys::windows::user_unique_id() +} + +#[cfg(unix)] +fn is_trusted_dir_stat(st: &sys::Stat) -> bool { + (st.st_mode & libc::S_IFMT) == libc::S_IFDIR + && st.st_uid == bun_sys::c::geteuid() + && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 +} + +/// `true` when `path` lstat's as a directory owned by the current uid with no +/// group/other write bits, or does not exist. Any other result fails closed. +#[cfg(unix)] +fn is_trusted_cache_root(path: &ZStr) -> bool { + match sys::lstat(path) { + Ok(st) => is_trusted_dir_stat(&st), + Err(e) if e.get_errno() == sys::E::ENOENT => true, + Err(_) => false, + } +} + +/// Same predicate as `is_trusted_cache_root`, on the directory we opened. +#[cfg(unix)] +fn is_trusted_opened_cache_dir(fd: Fd) -> bool { + sys::fstat(fd).is_ok_and(|st| is_trusted_dir_stat(&st)) +} + +#[cfg(not(unix))] +#[inline(always)] +fn is_trusted_cache_root(_path: &ZStr) -> bool { + true +} + +#[cfg(not(unix))] +#[inline(always)] +fn is_trusted_opened_cache_dir(_fd: Fd) -> bool { + true +} + /// Allocate `len` bytes and fill them via `pread_all` at `offset`, returning /// `MissingData` on a short read. /// @@ -702,8 +749,13 @@ impl RuntimeTranspilerCache { // that `absBufZ` used. let top = FileSystem::instance().top_level_dir; + let mut seg = [0u8; 4 + 10]; + seg[..4].copy_from_slice(b"@t@-"); + let n = bun_core::fmt::print_int(&mut seg[4..], current_user_id()); + let tcache_seg: &[u8] = &seg[..4 + n]; + if let Some(dir) = env_var::XDG_CACHE_HOME.get() { - let parts: &[&[u8]] = &[dir, b"bun", b"@t@"]; + let parts: &[&[u8]] = &[dir, b"bun", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -717,7 +769,7 @@ impl RuntimeTranspilerCache { // On a mac, default to ~/Library/Caches/bun/* // This is different than ~/.bun/install/cache, and not configurable by the user. if let Some(home) = env_var::HOME.get() { - let parts: &[&[u8]] = &[home, b"Library/", b"Caches/", b"bun", b"@t@"]; + let parts: &[&[u8]] = &[home, b"Library/", b"Caches/", b"bun", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -728,7 +780,7 @@ impl RuntimeTranspilerCache { } if let Some(dir) = env_var::HOME.get() { - let parts: &[&[u8]] = &[dir, b".bun", b"install", b"cache", b"@t@"]; + let parts: &[&[u8]] = &[dir, b".bun", b"install", b"cache", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -760,8 +812,17 @@ impl RuntimeTranspilerCache { let path_len = match Self::RUNTIME_TRANSPILER_CACHE.with(|c| c.get()) { Some(len) => len, None => { - let len = Self::CACHE_DIR_BUF - .with_borrow_mut(|tl_buf| Self::really_get_cache_dir(tl_buf)); + let len = Self::CACHE_DIR_BUF.with_borrow_mut(|tl_buf| { + let len = Self::really_get_cache_dir(tl_buf); + if len > 0 && !is_trusted_cache_root(ZStr::from_buf(&tl_buf[..], len)) { + bun_core::scoped_log!( + cache, + "transpiler cache root failed ownership/mode check, disabling" + ); + return 0; + } + len + }); if len == 0 { IS_DISABLED.store(true, Ordering::Relaxed); return Err(crate::CrateError::CacheDisabled); @@ -899,6 +960,11 @@ impl RuntimeTranspilerCache { } }); + if cache_dir_fd != Fd::cwd() && !is_trusted_opened_cache_dir(cache_dir_fd) { + IS_DISABLED.store(true, Ordering::Relaxed); + return Err(crate::CrateError::CacheDisabled); + } + Entry::save( cache_dir_fd, cache_file_path, diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cd3d67c1fea..722343d2cff 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -4952,13 +4952,14 @@ pub mod c { use core::ffi::{c_char, c_void}; #[cfg(unix)] pub use libc::fchmod; - // `getuid`/`getgid` take no args and read kernel + // `getuid`/`geteuid`/`getgid` take no args and read kernel // process state — no preconditions, never fail. Declared locally as // `safe fn` (instead of re-exporting the `libc` crate's raw decls) so // callers need no per-site proof. #[cfg(unix)] unsafe extern "C" { pub safe fn getuid() -> libc::uid_t; + pub safe fn geteuid() -> libc::uid_t; pub safe fn getgid() -> libc::gid_t; } #[cfg(unix)] diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 01d569bb15a..8be3d530dd1 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -1,7 +1,7 @@ import { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, bunRun, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunRun, isPosix, tmpdirSync } from "harness"; import { join } from "path"; function dummyFile(size: number, cache_bust: string, value: string | { code: string }) { @@ -140,8 +140,8 @@ describe("transpiler cache", () => { // Stand-in for the shared, world-writable system temp dir. Pre-create // bun/@t@ inside it the way another local user could on a multi-user host. const shared_tmp = join(temp_dir, "shared-tmp"); - const shared_cache = join(shared_tmp, "bun", "@t@"); - mkdirSync(shared_cache, { recursive: true }); + const shared_bun = join(shared_tmp, "bun"); + mkdirSync(join(shared_bun, "@t@"), { recursive: true }); // No per-user cache location is available (no BUN_RUNTIME_TRANSPILER_CACHE_PATH, // no XDG_CACHE_HOME, no HOME) — the only remaining candidate is the shared @@ -160,16 +160,17 @@ describe("transpiler cache", () => { }), ).toSpawn("no-tmpdir-cache"); - // No cache entry may be written into (or read back from) a directory that - // another local user could own and pre-populate. - expect(readdirSync(shared_cache)).toEqual([]); + // Nothing may be written under the shared dir, whatever leaf name a + // fallback would pick: only the pre-created decoy remains, and it is empty. + expect(readdirSync(shared_bun)).toEqual(["@t@"]); + expect(readdirSync(join(shared_bun, "@t@"))).toEqual([]); // A per-user cache location still works. expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("no-tmpdir-cache"); expect(newCacheCount()).toBe(1); }); test("works if the cache is not user-readable", async () => { - mkdirSync(cache_dir, { recursive: true }); + mkdirSync(cache_dir, { recursive: true, mode: 0o755 }); writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "b")); expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b"); expect(newCacheCount()).toBe(1); @@ -292,6 +293,105 @@ describe("transpiler cache", () => { expect(newCacheCount()).toBe(0); }); }); + + test("rejects a cache entry whose output was rewritten with a zeroed output_hash", async () => { + // Cache entry header (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): + // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, + // 6: features_hash u64, 14: input_byte_length u64, 22: input_hash u64, + // 30: output_byte_offset u64, 38: output_byte_length u64, + // 46: output_hash u64, ...; payload follows the 102-byte header. + const OUTPUT_BYTE_OFFSET = 30; + const OUTPUT_BYTE_LENGTH = 38; + const OUTPUT_HASH = 46; + + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "forge", "GOOD")); + + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const entries = readdirSync(cache_dir); + expect(entries.length).toBe(1); + + // Tamper: rewrite the printed literal inside the stored output and clear + // output_hash so the (forgeable) self-check would have accepted it. + const pile = join(cache_dir, entries[0]); + const buf = Buffer.from(readFileSync(pile)); + const outOff = Number(buf.readBigUInt64LE(OUTPUT_BYTE_OFFSET)); + const outLen = Number(buf.readBigUInt64LE(OUTPUT_BYTE_LENGTH)); + const region = buf.subarray(outOff, outOff + outLen); + const needle = region.indexOf("GOOD"); + expect(needle).toBeGreaterThanOrEqual(0); + region.write("EVIL", needle, "latin1"); + buf.writeBigUInt64LE(0n, OUTPUT_HASH); + writeFileSync(pile, buf); + + // The tampered entry must be rejected and the source re-transpiled: the + // original output is observed and a fresh entry replaces the forged one. + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const after = readdirSync(cache_dir); + expect(after.length).toBe(1); + expect(Buffer.from(readFileSync(join(cache_dir, after[0]))).readBigUInt64LE(OUTPUT_HASH)).not.toBe(0n); + }); + + test("rejects a cache entry whose output_hash was recomputed with the fixed seed", async () => { + const OUTPUT_BYTE_OFFSET = 30; + const OUTPUT_BYTE_LENGTH = 38; + const OUTPUT_HASH = 46; + + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "forge2", "GOOD")); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const entries = readdirSync(cache_dir); + expect(entries.length).toBe(1); + + // Section hashes are keyed on the per-entry input hash, not the fixed + // seed, so a wyhash(seed=42) over the tampered output must still be + // rejected. + const pile = join(cache_dir, entries[0]); + const buf = Buffer.from(readFileSync(pile)); + const outOff = Number(buf.readBigUInt64LE(OUTPUT_BYTE_OFFSET)); + const outLen = Number(buf.readBigUInt64LE(OUTPUT_BYTE_LENGTH)); + const region = buf.subarray(outOff, outOff + outLen); + const needle = region.indexOf("GOOD"); + expect(needle).toBeGreaterThanOrEqual(0); + region.write("EVIL", needle, "latin1"); + buf.writeBigUInt64LE(Bun.hash.wyhash(region, 42n), OUTPUT_HASH); + writeFileSync(pile, buf); + + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + }); + + test.skipIf(!isPosix)("disables the cache when the cache root is writable by other users", async () => { + // An attacker-controlled cache root (group/other-writable) must not be + // read from or written to: the transpiler cache is disabled for the run. + mkdirSync(cache_dir, { recursive: true }); + chmodSync(cache_dir, 0o777); + try { + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "trust", "ok")); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ok"); + expect(readdirSync(cache_dir)).toEqual([]); + } finally { + chmodSync(cache_dir, 0o755); + } + }); + + test.skipIf(!isPosix)("stops writing when the cache root turns untrusted after the initial check", async () => { + // The root is validated once per process when it is first resolved. The + // directory actually opened for each write is re-validated, so a root that + // becomes group/other-writable later in the same process receives no + // further entries. + const filler = "\n//" + Buffer.alloc(50 * 1024, "f").toString(); + writeFileSync( + join(temp_dir, "a.js"), + `require("fs").chmodSync(${JSON.stringify(cache_dir)}, 0o777); +require("./b.js");${filler}`, + ); + writeFileSync(join(temp_dir, "b.js"), dummyFile(50 * 1024, "late", "late-ok")); + try { + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("late-ok"); + // Only a.js (written while the root was still trusted) is cached. + expect(readdirSync(cache_dir).length).toBe(1); + } finally { + chmodSync(cache_dir, 0o755); + } + }); }); test("rejects cached module records containing out-of-range string indices", () => { @@ -310,6 +410,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 +435,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; }