Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
40200931fd fix(security): prevent symlink chain path traversal in archive extraction
Chained symlinks in a tarball could escape the extraction directory.
The existing isSymlinkTargetSafe() validates symlink targets against
logical archive paths but doesn't consider the actual filesystem state
created by previously extracted entries. A chain of individually-safe
symlinks (e.g., x/a -> ., x/a/b -> ., x/a/b/c -> ../..) can combine
to resolve outside the extraction boundary.

Add isResolvedPathSafe() which uses realpath to verify the actual
filesystem resolution stays within the extraction directory. Apply this
check after creating symlinks (removing unsafe ones) and before creating
files or directories (skipping entries whose parent escapes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 04:57:49 +00:00
7 changed files with 292 additions and 115 deletions

View File

@@ -1,108 +0,0 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { bench, group, run } from "../runner.mjs";
// Benchmark 1: queueMicrotask throughput
// Tests the BunPerformMicrotaskJob handler path directly.
// The optimization removes the JS trampoline and uses callMicrotask.
group("queueMicrotask throughput", () => {
bench("queueMicrotask 1k", () => {
return new Promise(resolve => {
let remaining = 1000;
const tick = () => {
if (--remaining === 0) resolve();
else queueMicrotask(tick);
};
queueMicrotask(tick);
});
});
bench("queueMicrotask 10k", () => {
return new Promise(resolve => {
let remaining = 10000;
const tick = () => {
if (--remaining === 0) resolve();
else queueMicrotask(tick);
};
queueMicrotask(tick);
});
});
});
// Benchmark 2: Promise.resolve chain
// Each .then() queues a microtask via the promise machinery.
// Benefits from smaller QueuedTask (better cache locality in the Deque).
group("Promise.resolve chain", () => {
bench("Promise chain 1k", () => {
let p = Promise.resolve();
for (let i = 0; i < 1000; i++) {
p = p.then(() => {});
}
return p;
});
bench("Promise chain 10k", () => {
let p = Promise.resolve();
for (let i = 0; i < 10000; i++) {
p = p.then(() => {});
}
return p;
});
});
// Benchmark 3: Promise.all (many simultaneous resolves)
// All promises resolve at once, flooding the microtask queue.
// Smaller QueuedTask = less memory, better cache utilization.
group("Promise.all simultaneous", () => {
bench("Promise.all 1k", () => {
const promises = [];
for (let i = 0; i < 1000; i++) {
promises.push(Promise.resolve(i));
}
return Promise.all(promises);
});
bench("Promise.all 10k", () => {
const promises = [];
for (let i = 0; i < 10000; i++) {
promises.push(Promise.resolve(i));
}
return Promise.all(promises);
});
});
// Benchmark 4: queueMicrotask with AsyncLocalStorage
// Tests the inlined async context save/restore path.
// Previously went through performMicrotaskFunction JS trampoline.
group("queueMicrotask + AsyncLocalStorage", () => {
const als = new AsyncLocalStorage();
bench("ALS.run + queueMicrotask 1k", () => {
return als.run({ id: 1 }, () => {
return new Promise(resolve => {
let remaining = 1000;
const tick = () => {
als.getStore(); // force context read
if (--remaining === 0) resolve();
else queueMicrotask(tick);
};
queueMicrotask(tick);
});
});
});
});
// Benchmark 5: async/await (each await queues microtasks)
group("async/await chain", () => {
async function asyncChain(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += await Promise.resolve(i);
}
return sum;
}
bench("async/await 1k", () => asyncChain(1000));
bench("async/await 10k", () => asyncChain(10000));
});
await run();

View File

@@ -6,7 +6,7 @@ option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of down
option(WEBKIT_BUILD_TYPE "The build type for local WebKit (defaults to CMAKE_BUILD_TYPE)")
if(NOT WEBKIT_VERSION)
set(WEBKIT_VERSION preview-pr-160-8680a32c)
set(WEBKIT_VERSION 8af7958ff0e2a4787569edf64641a1ae7cfe074a)
endif()
# Use preview build URL for Windows ARM64 until the fix is merged to main

View File

@@ -1061,7 +1061,9 @@ JSC_DEFINE_HOST_FUNCTION(functionQueueMicrotask,
auto* globalObject = defaultGlobalObject(lexicalGlobalObject);
JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0);
auto function = globalObject->performMicrotaskFunction();
#if ASSERT_ENABLED
ASSERT_WITH_MESSAGE(function, "Invalid microtask function");
ASSERT_WITH_MESSAGE(!callback.isEmpty(), "Invalid microtask callback");
#endif
@@ -1069,8 +1071,10 @@ JSC_DEFINE_HOST_FUNCTION(functionQueueMicrotask,
asyncContext = JSC::jsUndefined();
}
// BunPerformMicrotaskJob: callback, asyncContext
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, callback, asyncContext };
// BunPerformMicrotaskJob accepts a variable number of arguments (up to: performMicrotask, job, asyncContext, arg0, arg1).
// The runtime inspects argumentCount to determine which arguments are present, so callers may pass only the subset they need.
// Here we pass: function, callback, asyncContext.
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, function, callback, asyncContext };
globalObject->vm().queueMicrotask(WTF::move(task));
return JSC::JSValue::encode(JSC::jsUndefined());
@@ -1550,6 +1554,63 @@ extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* global
return globalObject->makeNapiEnvForFFI();
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionPerformMicrotask, (JSGlobalObject * globalObject, CallFrame* callframe))
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
auto job = callframe->argument(0);
if (!job || job.isUndefinedOrNull()) [[unlikely]] {
return JSValue::encode(jsUndefined());
}
auto callData = JSC::getCallData(job);
MarkedArgumentBuffer arguments;
if (callData.type == CallData::Type::None) [[unlikely]] {
return JSValue::encode(jsUndefined());
}
JSValue result;
WTF::NakedPtr<JSC::Exception> exceptionPtr;
JSValue restoreAsyncContext = {};
InternalFieldTuple* asyncContextData = nullptr;
auto setAsyncContext = callframe->argument(1);
if (!setAsyncContext.isUndefined()) {
asyncContextData = globalObject->m_asyncContextData.get();
restoreAsyncContext = asyncContextData->getInternalField(0);
asyncContextData->putInternalField(vm, 0, setAsyncContext);
}
size_t argCount = callframe->argumentCount();
switch (argCount) {
case 3: {
arguments.append(callframe->uncheckedArgument(2));
break;
}
case 4: {
arguments.append(callframe->uncheckedArgument(2));
arguments.append(callframe->uncheckedArgument(3));
break;
}
default:
break;
}
JSC::profiledCall(globalObject, ProfilingReason::API, job, callData, jsUndefined(), arguments, exceptionPtr);
if (asyncContextData) {
asyncContextData->putInternalField(vm, 0, restoreAsyncContext);
}
if (auto* exception = exceptionPtr.get()) {
Bun__reportUnhandledError(globalObject, JSValue::encode(exception));
}
return JSValue::encode(jsUndefined());
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionPerformMicrotaskVariadic, (JSGlobalObject * globalObject, CallFrame* callframe))
{
auto& vm = JSC::getVM(globalObject);
@@ -1879,6 +1940,11 @@ void GlobalObject::finishCreation(VM& vm)
scope.assertNoExceptionExceptTermination();
init.set(subclassStructure);
});
m_performMicrotaskFunction.initLater(
[](const Initializer<JSFunction>& init) {
init.set(JSFunction::create(init.vm, init.owner, 4, "performMicrotask"_s, jsFunctionPerformMicrotask, ImplementationVisibility::Public));
});
m_performMicrotaskVariadicFunction.initLater(
[](const Initializer<JSFunction>& init) {
init.set(JSFunction::create(init.vm, init.owner, 4, "performMicrotaskVariadic"_s, jsFunctionPerformMicrotaskVariadic, ImplementationVisibility::Public));

View File

@@ -272,6 +272,7 @@ public:
JSC::JSObject* performanceObject() const { return m_performanceObject.getInitializedOnMainThread(this); }
JSC::JSFunction* performMicrotaskFunction() const { return m_performMicrotaskFunction.getInitializedOnMainThread(this); }
JSC::JSFunction* performMicrotaskVariadicFunction() const { return m_performMicrotaskVariadicFunction.getInitializedOnMainThread(this); }
JSC::Structure* utilInspectOptionsStructure() const { return m_utilInspectOptionsStructure.getInitializedOnMainThread(this); }
@@ -568,6 +569,7 @@ public:
V(private, LazyPropertyOfGlobalObject<Structure>, m_jsonlParseResultStructure) \
V(private, LazyPropertyOfGlobalObject<Structure>, m_pathParsedObjectStructure) \
V(private, LazyPropertyOfGlobalObject<Structure>, m_pendingVirtualModuleResultStructure) \
V(private, LazyPropertyOfGlobalObject<JSFunction>, m_performMicrotaskFunction) \
V(private, LazyPropertyOfGlobalObject<JSFunction>, m_nativeMicrotaskTrampoline) \
V(private, LazyPropertyOfGlobalObject<JSFunction>, m_performMicrotaskVariadicFunction) \
V(private, LazyPropertyOfGlobalObject<JSFunction>, m_utilInspectFunction) \

View File

@@ -3538,11 +3538,13 @@ void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::J
promise->internalField(JSC::JSPromise::Field::Flags).set(vm, promise, jsNumber(flags | JSC::JSPromise::isFirstResolvingFunctionCalledFlag));
auto* globalObject = jsCast<Zig::GlobalObject*>(promise->globalObject());
auto microtaskFunction = globalObject->performMicrotaskFunction();
auto rejectPromiseFunction = globalObject->rejectPromiseFunction();
auto asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0);
#if ASSERT_ENABLED
ASSERT_WITH_MESSAGE(microtaskFunction, "Invalid microtask function");
ASSERT_WITH_MESSAGE(rejectPromiseFunction, "Invalid microtask callback");
ASSERT_WITH_MESSAGE(!value.isEmpty(), "Invalid microtask value");
#endif
@@ -3555,8 +3557,7 @@ void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::J
value = jsUndefined();
}
// BunPerformMicrotaskJob: rejectPromiseFunction, asyncContext, promise, value
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, microtaskFunction, rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value };
globalObject->vm().queueMicrotask(WTF::move(task));
RETURN_IF_EXCEPTION(scope, );
}
@@ -5437,7 +5438,9 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0
if (microtaskArgs[3].isEmpty()) {
microtaskArgs[3] = jsUndefined();
}
JSC::JSFunction* microTaskFunction = globalObject->performMicrotaskFunction();
#if ASSERT_ENABLED
ASSERT_WITH_MESSAGE(microTaskFunction, "Invalid microtask function");
auto& vm = globalObject->vm();
if (microtaskArgs[0].isCell()) {
JSC::Integrity::auditCellFully(vm, microtaskArgs[0].asCell());
@@ -5457,8 +5460,7 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0
#endif
// BunPerformMicrotaskJob: job, asyncContext, arg0, arg1
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, WTF::move(microtaskArgs[0]), WTF::move(microtaskArgs[1]), WTF::move(microtaskArgs[2]), WTF::move(microtaskArgs[3]) };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, microTaskFunction, WTF::move(microtaskArgs[0]), WTF::move(microtaskArgs[1]), WTF::move(microtaskArgs[2]), WTF::move(microtaskArgs[3]) };
globalObject->vm().queueMicrotask(WTF::move(task));
}

View File

@@ -205,6 +205,42 @@ fn isSymlinkTargetSafe(symlink_path: []const u8, link_target: [:0]const u8, syml
return strings.hasPrefix(resolved, fake_root);
}
/// Verifies that a path within the extraction directory resolves (via realpath,
/// which follows all symlinks) to a location still within the extraction directory.
/// This catches chained symlink attacks where individually-safe symlinks combine
/// to escape the extraction boundary.
///
/// Returns true if the resolved path is safe (stays within extraction dir).
fn isResolvedPathSafe(dir: std.fs.Dir, path_slice: []const u8, realpath_buf: *bun.PathBuffer) bool {
// Resolve the real filesystem path, following all symlinks.
const resolved = dir.realpath(path_slice, realpath_buf) catch {
// If we can't resolve (e.g., broken symlink), treat as safe since
// the path doesn't actually lead anywhere exploitable.
return true;
};
// Now get the real path of the extraction directory itself.
var dir_realpath_buf: bun.PathBuffer = undefined;
const dir_resolved = dir.realpath(".", &dir_realpath_buf) catch {
// If we can't resolve the extraction dir itself, allow the operation
// since we can't perform the check.
return true;
};
// The resolved path must start with the extraction directory's real path.
if (!strings.hasPrefix(resolved, dir_resolved)) {
return false;
}
// Also verify there's either nothing after the prefix, or a path separator follows.
// This prevents "/extract2" from matching "/extract".
if (resolved.len > dir_resolved.len and resolved[dir_resolved.len] != '/') {
return false;
}
return true;
}
pub const Archiver = struct {
// impl: *lib.archive = undefined,
// buf: []const u8 = undefined,
@@ -353,6 +389,8 @@ pub const Archiver = struct {
var symlink_join_buf: ?*bun.PathBuffer = null;
defer if (symlink_join_buf) |join_buf| bun.path_buffer_pool.put(join_buf);
var realpath_buf: bun.PathBuffer = undefined;
var normalized_buf: bun.OSPathBuffer = undefined;
var use_pwrite = Environment.isPosix;
var use_lseek = true;
@@ -446,6 +484,21 @@ pub const Archiver = struct {
switch (kind) {
.directory => {
if (Environment.isPosix) {
// Verify that the directory's parent resolves within the
// extraction directory. This prevents creating directories
// through symlink chains that escape the extraction boundary.
const parent_dir = std.fs.path.dirname(path_slice) orelse "";
if (parent_dir.len > 0 and !isResolvedPathSafe(dir, parent_dir, &realpath_buf)) {
if (options.log) {
Output.warn("Skipping directory whose parent resolves outside extraction dir: {f}\n", .{
bun.fmt.fmtOSPath(path_slice, .{}),
});
}
continue;
}
}
var mode = @as(i32, @intCast(entry.perm()));
// if dirs are readable, then they should be listable
@@ -495,9 +548,41 @@ pub const Archiver = struct {
else => return err,
}
};
// After creating the symlink, verify that it resolves to a path
// within the extraction directory. This catches chained symlink
// attacks where multiple individually-safe symlinks combine to
// escape the extraction boundary.
if (!isResolvedPathSafe(dir, path_slice, &realpath_buf)) {
// The symlink chain resolves outside the extraction dir.
// Remove the symlink we just created and skip this entry.
dir.deleteFile(path_slice) catch {};
if (options.log) {
Output.warn("Removing symlink whose resolved path escapes extraction dir: {f} -> {s}\n", .{
bun.fmt.fmtOSPath(path_slice, .{}),
link_target,
});
}
continue;
}
}
},
.file => {
if (Environment.isPosix) {
// Verify that the file's parent directory resolves within the
// extraction directory. This prevents writing files through
// symlink chains that escape the extraction boundary.
const parent_dir = std.fs.path.dirname(path_slice) orelse "";
if (parent_dir.len > 0 and !isResolvedPathSafe(dir, parent_dir, &realpath_buf)) {
if (options.log) {
Output.warn("Skipping file whose parent resolves outside extraction dir: {f}\n", .{
bun.fmt.fmtOSPath(path_slice, .{}),
});
}
continue;
}
}
// first https://github.com/npm/cli/blob/feb54f7e9a39bd52519221bae4fafc8bc70f235e/node_modules/pacote/lib/fetcher.js#L65-L66
// this.fmode = opts.fmode || 0o666
//

View File

@@ -0,0 +1,130 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, realpathSync, symlinkSync } from "fs";
import { bunEnv, bunExe, tempDir, tls } from "harness";
import { dirname, join } from "path";
// Symlink chain path traversal vulnerability in archive extraction (libarchive.zig).
//
// The attack uses a 3-level chain of symlinks to escape extraction bounds:
// 1. `x/a` -> `.` creates a self-referencing symlink (resolves to x/ itself)
// 2. `x/a/b` -> `.` through the first symlink creates x/b -> . (also self-referencing)
// 3. `x/a/b/c` -> `../..` through both symlinks creates x/c -> ../..
// which resolves from x/ to two levels up = parent of extraction dir
//
// isSymlinkTargetSafe passes all three because it only checks logical path
// resolution without considering previously-created symlinks on the filesystem.
describe("symlink chain path traversal", () => {
test("chained symlinks in tarball should not escape extraction directory via bun create", async () => {
using dir = tempDir("symlink-chain-test", {});
const baseDir = String(dir);
const tgzPath = join(baseDir, "malicious.tgz");
// Create a malicious tarball with 3-level symlink chain.
// root/ prefix gets stripped by depth_to_skip=1 (matching bun create behavior).
const pyResult = Bun.spawnSync({
cmd: [
"python3",
"-c",
`
import tarfile, io
with tarfile.open(${JSON.stringify(tgzPath)}, 'w:gz') as tar:
for name, typ, extra in [
('root/', tarfile.DIRTYPE, {}),
('root/x/', tarfile.DIRTYPE, {}),
('root/x/a', tarfile.SYMTYPE, {'linkname': '.'}),
('root/x/a/b', tarfile.SYMTYPE, {'linkname': '.'}),
('root/x/a/b/c', tarfile.SYMTYPE, {'linkname': '../..'}),
]:
info = tarfile.TarInfo(name=name)
info.type = typ
info.mode = 0o755
for k, v in extra.items():
setattr(info, k, v)
tar.addfile(info)
content = b'PWNED_CANARY'
info = tarfile.TarInfo(name='root/x/c/evil.txt')
info.type = tarfile.REGTYPE
info.size = len(content)
info.mode = 0o644
tar.addfile(info, io.BytesIO(content))
`,
],
env: bunEnv,
});
expect(pyResult.exitCode).toBe(0);
expect(existsSync(tgzPath)).toBe(true);
// Serve the tarball over HTTPS, mimicking the GitHub API tarball endpoint.
await using server = Bun.serve({
port: 0,
tls,
fetch(req) {
if (new URL(req.url).pathname === "/repos/test/malicious/tarball") {
return new Response(Bun.file(tgzPath), {
headers: { "content-type": "application/x-gzip" },
});
}
return new Response("not found", { status: 404 });
},
});
const createDir = join(baseDir, "created-project");
await using proc = Bun.spawn({
cmd: [bunExe(), "create", "test/malicious", createDir],
env: {
...bunEnv,
GITHUB_API_DOMAIN: `localhost:${server.port}`,
NODE_TLS_REJECT_UNAUTHORIZED: "0",
},
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// x/c -> ../.. resolves from createDir/x/ up two levels to dirname(createDir)
const evilOutside = join(dirname(createDir), "evil.txt");
const fileEscaped = existsSync(evilOutside);
if (fileEscaped) {
console.log("VULNERABILITY: evil.txt written outside extraction directory at", evilOutside);
Bun.spawnSync({ cmd: ["rm", "-f", evilOutside], env: bunEnv });
}
// Security requirement: no file should exist outside the extraction directory
expect(fileEscaped).toBe(false);
// Verify: if x/c symlink exists, it should not resolve outside createDir
const escapeLink = join(createDir, "x", "c");
if (existsSync(escapeLink)) {
try {
const resolved = realpathSync(escapeLink);
expect(resolved.startsWith(createDir)).toBe(true);
} catch {
// Broken symlink is OK - the escape was prevented
}
}
});
test("concept validation: 3-level symlink chain escapes extraction directory", () => {
// Validates the attack concept independently of Bun's extraction.
using dir = tempDir("symlink-concept-test", {});
const baseDir = String(dir);
const extractDir = join(baseDir, "extract");
mkdirSync(join(extractDir, "x"), { recursive: true });
// Chain: x/a -> . , x/a/b -> . , x/a/b/c -> ../..
symlinkSync(".", join(extractDir, "x", "a"));
symlinkSync(".", join(extractDir, "x", "a", "b"));
symlinkSync("../..", join(extractDir, "x", "a", "b", "c"));
// x/c -> ../.. from x/ goes two levels up: extract/ -> baseDir/
const escapeResolved = realpathSync(join(extractDir, "x", "c"));
expect(escapeResolved).toBe(baseDir);
expect(escapeResolved.startsWith(extractDir)).toBe(false);
});
});