mirror of
https://github.com/oven-sh/bun
synced 2026-02-18 14:51:52 +00:00
Compare commits
1 Commits
claude/opt
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40200931fd |
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
130
test/regression/issue/symlink-chain-traversal.test.ts
Normal file
130
test/regression/issue/symlink-chain-traversal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user