diff --git a/src/jsc/bindings/wtf-bindings.cpp b/src/jsc/bindings/wtf-bindings.cpp index 0d4968f805fc..1fdfbcefd74f 100644 --- a/src/jsc/bindings/wtf-bindings.cpp +++ b/src/jsc/bindings/wtf-bindings.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "wtf/SIMDUTF.h" diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index d8d2b3a70694..d747b9e86a53 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -371,6 +371,14 @@ fn intern_tsconfig_contents(contents: crate::cache::Contents) -> &'static [u8] { // surface the bust log. bun_core::define_scoped_log!(debuglog, Resolver, hidden); +#[inline] +fn is_permission_denied_dir_read_error(err: bun_core::Error) -> bool { + err == bun_core::err!("AccessDenied") + || err == bun_core::err!("PermissionDenied") + || err == bun_core::err!("EACCES") + || err == bun_core::err!("EPERM") +} + // PORT NOTE: `Path` in the body is the `'static`-interned variant (paths borrow // DirnameStore/FilenameStore). Alias here so the ~80 bare-`Path` use sites // resolve without a per-site lifetime annotation. @@ -4250,6 +4258,11 @@ impl<'a> Resolver<'a> { if cfg!(debug_assertions) { debug_assert!(queue_slice_len > 0); } + let missing_parent_result = allocators::Result { + index: allocators::NOT_FOUND, + hash: 0, + status: allocators::ItemStatus::NotFound, + }; let open_dir_count = core::cell::Cell::new(0usize); // When this function halts, any item not processed means it's not found. @@ -4284,9 +4297,10 @@ impl<'a> Resolver<'a> { // - fts_open is not the fastest way to read directories. fts actually just uses readdir!! // - remember let mut _safe_path: Option<&'static [u8]> = None; + let mut parent_result = top_parent; // Start at the top. - while queue_slice_len > 0 { + 'queue_loop: while queue_slice_len > 0 { // SAFETY: every slot in `0..queue_slice_len` was `.write()`-initialised above. let mut queue_top = unsafe { bufs!(dir_entry_paths_to_resolve)[queue_slice_len - 1].assume_init_ref() } @@ -4301,6 +4315,7 @@ impl<'a> Resolver<'a> { let queue_top_safe_path: &[u8] = qt_safe_path.slice(); // defer top_parent = queue_top.result — done at end of loop body queue_slice_len -= 1; + let is_target_dir = queue_slice_len == 0; let open_dir: FD = if queue_top.fd.is_valid() { queue_top.fd @@ -4364,6 +4379,15 @@ impl<'a> Resolver<'a> { { return Ok(None); } + // On POSIX, execute-only ancestors can be traversed but not read. + // Keep walking toward the target directory instead of failing cwd + // resolution just because an intermediate directory can't be listed. + // Treat the skipped ancestor as an inheritance boundary so readable + // parents above it do not leak package metadata into the child. + if !is_target_dir && is_permission_denied_dir_read_error(err) { + parent_result = missing_parent_result; + continue 'queue_loop; + } let cached_dir_entry_result = rfs!() .entries .get_or_put(queue_top_unsafe_path) @@ -4562,7 +4586,7 @@ impl<'a> Resolver<'a> { let dc = self.dir_cache_mut(); let dir_info_ptr: *mut DirInfo::DirInfo = dc.put(&mut queue_top.result, DirInfo::DirInfo::default())?; - let parent_dir_ptr = dc.at_index(top_parent.index).map(DirInfoRef::from_slot); + let parent_dir_ptr = dc.at_index(parent_result.index).map(DirInfoRef::from_slot); self.dir_info_uncached( dir_info_ptr, @@ -4572,12 +4596,12 @@ impl<'a> Resolver<'a> { queue_top.result, cached_dir_entry_result.index, parent_dir_ptr, - top_parent.index, + parent_result.index, open_dir, None, )?; - top_parent = queue_top.result; + parent_result = queue_top.result; if queue_slice_len == 0 { // SAFETY: `dir_info_ptr` is the BSSMap slot just filled by `dir_info_uncached`. diff --git a/test/cli/run/run_command.test.ts b/test/cli/run/run_command.test.ts index 2c036b55bb68..343dc87e75bd 100644 --- a/test/cli/run/run_command.test.ts +++ b/test/cli/run/run_command.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; -import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, bunRun, isWindows } from "harness"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, bunRun, isLinux, isWindows, libcPathForDlopen, tempDir, tempDirWithFiles } from "harness"; +import { dirname, join } from "path"; let cwd: string; @@ -30,3 +31,229 @@ test.if(isWindows)("[windows] A file in drive root runs", () => { rmSync(path); } }); + +const LANDLOCK_HELPER_SRC = (libcPath: string) => ` +import { dlopen, ptr } from "bun:ffi"; + +const libc = dlopen(${JSON.stringify(libcPath)}, { + syscall: { args: ["i64_fast", "usize", "usize", "usize", "usize", "usize", "usize"], returns: "i64_fast" }, + prctl: { args: ["int", "usize", "usize", "usize", "usize"], returns: "int" }, + open: { args: ["ptr", "int"], returns: "int" }, + close: { args: ["int"], returns: "int" }, +}).symbols; + +const SYS_landlock_create_ruleset = 444; +const SYS_landlock_add_rule = 445; +const SYS_landlock_restrict_self = 446; +const LANDLOCK_RULE_PATH_BENEATH = 1; +const PR_SET_NO_NEW_PRIVS = 38; +const O_RDONLY = 0; +const O_DIRECTORY = 0x10000; +const O_CLOEXEC = 0x80000; +const O_PATH = 0x200000; + +const ACCESS_EXECUTE = 1n << 0n; +const ACCESS_WRITE_FILE = 1n << 1n; +const ACCESS_READ_FILE = 1n << 2n; +const ACCESS_READ_DIR = 1n << 3n; +const ACCESS_REMOVE_DIR = 1n << 4n; +const ACCESS_REMOVE_FILE = 1n << 5n; +const ACCESS_MAKE_CHAR = 1n << 6n; +const ACCESS_MAKE_DIR = 1n << 7n; +const ACCESS_MAKE_REG = 1n << 8n; +const ACCESS_MAKE_SOCK = 1n << 9n; +const ACCESS_MAKE_FIFO = 1n << 10n; +const ACCESS_MAKE_BLOCK = 1n << 11n; +const ACCESS_MAKE_SYM = 1n << 12n; +const allAccess = + ACCESS_EXECUTE | + ACCESS_WRITE_FILE | + ACCESS_READ_FILE | + ACCESS_READ_DIR | + ACCESS_REMOVE_DIR | + ACCESS_REMOVE_FILE | + ACCESS_MAKE_CHAR | + ACCESS_MAKE_DIR | + ACCESS_MAKE_REG | + ACCESS_MAKE_SOCK | + ACCESS_MAKE_FIFO | + ACCESS_MAKE_BLOCK | + ACCESS_MAKE_SYM; +const readExec = ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR; + +function cstr(value) { + return Buffer.from(value + "\\0"); +} + +function rulesetAttr(access) { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setBigUint64(0, access, true); + return buffer; +} + +function pathBeneathAttr(access, fd) { + const buffer = new ArrayBuffer(16); + const view = new DataView(buffer); + view.setBigUint64(0, access, true); + view.setInt32(8, fd, true); + return buffer; +} + +function addPathRule(rulesetFd, path, access) { + const pathBytes = cstr(path); + const fd = libc.open(ptr(pathBytes), O_PATH | O_CLOEXEC); + if (fd < 0) return; + const attr = pathBeneathAttr(access, fd); + libc.syscall(SYS_landlock_add_rule, rulesetFd, LANDLOCK_RULE_PATH_BENEATH, ptr(attr), 0, 0, 0); + libc.close(fd); +} + +function openDir(path) { + const pathBytes = cstr(path); + return libc.open(ptr(pathBytes), O_RDONLY | O_DIRECTORY); +} + +function restrictTo(allowedDir, bunDir) { + const ruleset = rulesetAttr(allAccess); + const rulesetFd = libc.syscall(SYS_landlock_create_ruleset, ptr(ruleset), ruleset.byteLength, 0, 0, 0, 0); + if (rulesetFd < 0) return false; + + addPathRule(rulesetFd, allowedDir, allAccess); + addPathRule(rulesetFd, bunDir, readExec); + addPathRule(rulesetFd, "/usr", readExec); + addPathRule(rulesetFd, "/lib", readExec); + addPathRule(rulesetFd, "/lib64", readExec); + addPathRule(rulesetFd, "/etc", ACCESS_READ_FILE | ACCESS_READ_DIR); + addPathRule(rulesetFd, "/proc", ACCESS_READ_FILE | ACCESS_READ_DIR); + addPathRule(rulesetFd, "/sys", ACCESS_READ_FILE | ACCESS_READ_DIR); + addPathRule(rulesetFd, "/dev", readExec | ACCESS_WRITE_FILE); + + if (libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) !== 0) return false; + const rc = libc.syscall(SYS_landlock_restrict_self, rulesetFd, 0, 0, 0, 0, 0); + libc.close(rulesetFd); + return rc === 0; +} + +const [allowedDir, bunDir, modeOrCommand, ...args] = process.argv.slice(2); +if (!restrictTo(allowedDir, bunDir)) { + console.log("LANDLOCK_UNSUPPORTED"); + process.exit(0); +} + +if (modeOrCommand === "--self-check") { + const rootFd = openDir("/"); + const allowedFd = openDir(allowedDir); + if (rootFd >= 0) libc.close(rootFd); + if (allowedFd >= 0) libc.close(allowedFd); + console.log(rootFd < 0 && allowedFd >= 0 ? "LANDLOCK_OK" : "LANDLOCK_UNSUPPORTED"); + process.exit(0); +} + +const result = Bun.spawnSync({ + cmd: [modeOrCommand, ...args], + cwd: process.cwd(), + env: process.env, + stdout: "inherit", + stderr: "inherit", +}); +process.exit(result.exitCode ?? 1); +`; + +function writeLandlockHelper(root: string) { + const helperPath = join(root, "landlock-helper.js"); + writeFileSync(helperPath, LANDLOCK_HELPER_SRC(libcPathForDlopen())); + return helperPath; +} + +function prepareLandlockFixture(root: string) { + const testBase = join(root, "parent", "project"); + const helperPath = writeLandlockHelper(root); + + mkdirSync(testBase, { recursive: true }); + + const check = Bun.spawnSync({ + cmd: [bunExe(), helperPath, testBase, dirname(bunExe()), "--self-check"], + env: bunEnv, + cwd: testBase, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = check.stdout.toString(); + if (stdout.includes("LANDLOCK_OK")) { + expect(check.exitCode).toBe(0); + } + + return { helperPath, testBase }; +} + +function canUseLandlock() { + if (!isLinux) return false; + + const root = tempDirWithFiles("run-landlock-probe", {}); + try { + const testBase = join(root, "parent", "project"); + const helperPath = writeLandlockHelper(root); + + mkdirSync(testBase, { recursive: true }); + const check = Bun.spawnSync({ + cmd: [bunExe(), helperPath, testBase, dirname(bunExe()), "--self-check"], + env: bunEnv, + cwd: testBase, + stdout: "pipe", + stderr: "pipe", + }); + + return check.exitCode === 0 && check.stdout.toString().includes("LANDLOCK_OK"); + } finally { + try { + rmSync(root, { recursive: true, force: true }); + } catch {} + } +} + +// https://github.com/oven-sh/bun/issues/30859 +// `bun run` should work when cwd is readable but one or more ancestors are +// inaccessible, as can happen inside Landlock-style sandboxes. +describe.skipIf(!canUseLandlock())("bun run in a Landlock sandbox", () => { + test("works when ancestor directories are inaccessible", () => { + using root = tempDir("run-landlock-ancestors", {}); + const { helperPath, testBase } = prepareLandlockFixture(String(root)); + + writeFileSync(join(testBase, "index.js"), "console.log(require('path').join('a', 'b', 'c'));\n"); + + const result = Bun.spawnSync({ + cmd: [bunExe(), helperPath, testBase, dirname(bunExe()), bunExe(), "run", "index.js"], + env: bunEnv, + cwd: testBase, + stdout: "pipe", + stderr: "pipe", + }); + + expect(result.stdout.toString()).toBe("a/b/c\n"); + expect(result.exitCode).toBe(0); + }); + + test("still fails when the target directory itself is inaccessible", () => { + using root = tempDir("run-landlock-target", {}); + const { helperPath } = prepareLandlockFixture(String(root)); + + const allowedBase = join(String(root), "allowed"); + const blockedBase = join(String(root), "blocked"); + mkdirSync(allowedBase, { recursive: true }); + mkdirSync(blockedBase, { recursive: true }); + writeFileSync(join(blockedBase, "index.js"), "console.log('should not run');\n"); + + const result = Bun.spawnSync({ + cmd: [bunExe(), helperPath, allowedBase, dirname(bunExe()), bunExe(), "run", "index.js"], + env: bunEnv, + cwd: blockedBase, + stdout: "pipe", + stderr: "pipe", + }); + + expect(result.stdout.toString()).toBeEmpty(); + expect(result.stderr.toString()).toContain("CouldntReadCurrentDirectory"); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 46fe4783d66a..781cb4e5ab92 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -740,6 +740,132 @@ describe.if(isWindows)("#30839 - imports entry pointing at a scoped package", () } catch {} } }); + + it.skipIf(!canTriggerEACCES)("module resolution skips unreadable cwd ancestors", async () => { + using dir = tempDir("resolver-inaccessible-ancestor", { + "blocked/project/entry.js": `import { value } from "pkg";\nconsole.log(value);`, + "blocked/project/node_modules/pkg/index.js": `export const value = "resolved";\n`, + "blocked/project/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + module: "index.js", + }), + }); + const root = String(dir); + const blocked = join(root, "blocked"); + const project = join(blocked, "project"); + + if (canUseRunuser) { + for (const p of [root, project, join(project, "node_modules"), join(project, "node_modules/pkg")]) { + chmodSync(p, 0o755); + } + for (const p of [ + join(project, "entry.js"), + join(project, "node_modules/pkg/index.js"), + join(project, "node_modules/pkg/package.json"), + ]) { + chmodSync(p, 0o644); + } + } + + try { + chmodSync(blocked, 0o111); + await using proc = Bun.spawn({ + cmd: canUseRunuser ? ["runuser", "-u", "nobody", "--", bunExe(), "entry.js"] : [bunExe(), "entry.js"], + env: bunEnv, + cwd: project, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("resolved\n"); + expect(exitCode).toBe(0); + } finally { + try { + chmodSync(blocked, 0o755); + } catch {} + } + }); + + it.skipIf(!canTriggerEACCES)("unreadable cwd ancestors stop parent package metadata inheritance", async () => { + using dir = tempDir("resolver-inaccessible-ancestor-boundary", { + "package.json": JSON.stringify({ + name: "parent-package", + imports: { + "#secret": "./secret.js", + }, + }), + "secret.js": `export const value = "leaked";\n`, + "blocked/project/entry.js": `import { value } from "#secret";\nconsole.log(value);`, + }); + const root = String(dir); + const blocked = join(root, "blocked"); + const project = join(blocked, "project"); + + if (canUseRunuser) { + for (const p of [root, blocked, project]) { + chmodSync(p, 0o755); + } + for (const p of [join(root, "package.json"), join(root, "secret.js"), join(project, "entry.js")]) { + chmodSync(p, 0o644); + } + } + + try { + chmodSync(blocked, 0o111); + await using proc = Bun.spawn({ + cmd: canUseRunuser ? ["runuser", "-u", "nobody", "--", bunExe(), "entry.js"] : [bunExe(), "entry.js"], + env: bunEnv, + cwd: project, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("#secret"); + expect(exitCode).not.toBe(0); + } finally { + try { + chmodSync(blocked, 0o755); + } catch {} + } + }); + + it.skipIf(!canTriggerEACCES)("module resolution still fails when cwd itself is unreadable", async () => { + using dir = tempDir("resolver-inaccessible-target", { + "project/entry.js": `console.log("should not run");\n`, + }); + const root = String(dir); + const project = join(root, "project"); + + if (canUseRunuser) { + chmodSync(root, 0o755); + chmodSync(project, 0o755); + chmodSync(join(project, "entry.js"), 0o644); + } + + try { + chmodSync(project, 0o111); + await using proc = Bun.spawn({ + cmd: canUseRunuser ? ["runuser", "-u", "nobody", "--", bunExe(), "entry.js"] : [bunExe(), "entry.js"], + env: bunEnv, + cwd: project, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).not.toBe(""); + expect(exitCode).not.toBe(0); + } finally { + try { + chmodSync(project, 0o755); + } catch {} + } + }); } describe("resolving external URL specifiers with non-ASCII characters", () => {