Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions src/jsc/bindings/wtf-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <wtf/StackTrace.h>
#include <wtf/dtoa.h>
#include <wtf/NumberOfCores.h>
#include <assert.h>
#include <atomic>

#include "wtf/SIMDUTF.h"
Expand Down
32 changes: 28 additions & 4 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() }
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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`.
Expand Down
231 changes: 229 additions & 2 deletions test/cli/run/run_command.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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 {}
}
}
Comment on lines +190 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the Landlock probe validate a real bun launch.

canUseLandlock() only checks that the ruleset can block / while still opening allowedDir. It never proves that the restricted allowlist is sufficient to execute bun, so this can return true and enable the suite even when the sandbox later fails for unrelated loader/libc path reasons. Probe with a trivial bun invocation under the helper so the skip condition matches the actual test preconditions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/run/run_command.test.ts` around lines 190 - 213, canUseLandlock()
currently only verifies the ruleset blocks / while allowing allowedDir but
doesn't confirm that a real bun process can start under that restricted
allowlist; update the probe so the helper (written by writeLandlockHelper)
actually launches a trivial bun invocation (e.g., bun --version or a minimal bun
script) inside the landlocked environment and the Bun.spawnSync call checks both
LANDLOCK_OK and the bun invocation's expected output; adjust the helper
invocation arguments (helperPath and the bunExe passed to it) and the
spawn/result checks in canUseLandlock() so the probe only returns true when the
helper reports LANDLOCK_OK and the launched bun command succeeds.


// 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);
});
});
Loading