Files
bun-src/test/cli/install/symlink-path-traversal.test.ts
T
2026-08-27 21:09:14 +00:00

1013 lines
37 KiB
TypeScript

import { spawn } from "bun";
import { describe, expect, it, setDefaultTimeout } from "bun:test";
import { access, chmod, lstat, mkdir, readdir, readlink, realpath, rm, stat, symlink, writeFile } from "fs/promises";
import { bunExe, bunEnv as env, tempDir } from "harness";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { tmpdir } from "os";
import { basename, join } from "path";
// This test validates the fix for a symlink path traversal vulnerability in tarball extraction.
// CVE: Path traversal via symlink when installing packages
//
// The attack works as follows:
// 1. Create a tarball with a symlink entry pointing outside (e.g., symlink -> ../../../tmp)
// 2. Include a file entry through that symlink path (e.g., symlink/pwned.txt)
// 3. On extraction, the symlink is created first
// 4. Then when the file is written through the symlink path, it escapes the extraction directory
//
// The fix validates symlink targets before creating them, blocking those that would escape.
//
// Note: These tests only run on POSIX systems as the symlink extraction code is POSIX-only.
// Platform-agnostic temp directory for testing path traversal
const systemTmpDir = tmpdir();
const pwnedFilePath = join(systemTmpDir, "pwned.txt");
// Helper to create tar files programmatically
function createTarHeader(
name: string,
size: number,
type: "0" | "2" | "5", // 0=file, 2=symlink, 5=directory
linkname: string = "",
): Uint8Array {
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// Name (100 bytes)
const nameBytes = encoder.encode(name);
header.set(nameBytes.slice(0, 100), 0);
// Mode (8 bytes) - octal
const modeStr = type === "5" ? "0000755" : "0000644";
header.set(encoder.encode(modeStr.padStart(7, "0") + " "), 100);
// UID (8 bytes)
header.set(encoder.encode("0000000 "), 108);
// GID (8 bytes)
header.set(encoder.encode("0000000 "), 116);
// Size (12 bytes) - octal
const sizeStr = size.toString(8).padStart(11, "0") + " ";
header.set(encoder.encode(sizeStr), 124);
// Mtime (12 bytes)
const mtime = Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0");
header.set(encoder.encode(mtime + " "), 136);
// Checksum placeholder (8 spaces)
header.set(encoder.encode(" "), 148);
// Type flag (1 byte)
header[156] = type.charCodeAt(0);
// Link name (100 bytes) - for symlinks
if (linkname) {
const linkBytes = encoder.encode(linkname);
header.set(linkBytes.slice(0, 100), 157);
}
// USTAR magic
header.set(encoder.encode("ustar"), 257);
header[262] = 0; // null terminator
header.set(encoder.encode("00"), 263);
// Calculate and set checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumStr = checksum.toString(8).padStart(6, "0") + "\0 ";
header.set(encoder.encode(checksumStr), 148);
return header;
}
function padToBlock(data: Uint8Array): Uint8Array[] {
const result = [data];
const remainder = data.length % 512;
if (remainder > 0) {
result.push(new Uint8Array(512 - remainder));
}
return result;
}
function createTarball(
entries: Array<{ name: string; type: "file" | "symlink" | "dir"; content?: string; linkname?: string }>,
): Uint8Array {
const blocks: Uint8Array[] = [];
const encoder = new TextEncoder();
for (const entry of entries) {
if (entry.type === "dir") {
blocks.push(createTarHeader(entry.name, 0, "5"));
} else if (entry.type === "symlink") {
blocks.push(createTarHeader(entry.name, 0, "2", entry.linkname || ""));
} else {
const content = encoder.encode(entry.content || "");
blocks.push(createTarHeader(entry.name, content.length, "0"));
blocks.push(...padToBlock(content));
}
}
// End of archive (two empty blocks)
blocks.push(new Uint8Array(512));
blocks.push(new Uint8Array(512));
// Combine all blocks
const totalLength = blocks.reduce((sum, b) => sum + b.length, 0);
const tarball = new Uint8Array(totalLength);
let offset = 0;
for (const block of blocks) {
tarball.set(block, offset);
offset += block.length;
}
return Bun.gzipSync(tarball);
}
// Skip on Windows - symlink extraction is POSIX-only
const isWindows = process.platform === "win32";
describe.concurrent.skipIf(isWindows)("symlink path traversal protection", () => {
setDefaultTimeout(60000);
it("rejects symlink targets that climb above the package root before re-entering a 'packages' directory (streaming extraction)", async () => {
// The streaming extractor used to validate symlink targets by joining
// them onto a fake absolute root ("/packages/") and checking the prefix
// of the normalized result. POSIX normalization clamps excess ".." at
// "/", so a target of the form "(../)+packages/<x>" normalized back
// under the fake root and passed the check, while the kernel resolves
// the raw ".." components from the symlink's real on-disk location and
// lands outside the extraction directory. Such targets must be rejected.
const escapeTarget = "../../../../packages/escape-target";
// Incompressible padding so the tarball body is delivered over many
// socket reads; the streaming extractor only takes over when the body
// arrives in multiple chunks.
let pad = "";
let seed = "streaming-symlink-pad";
while (pad.length < 256 * 1024) {
seed = createHash("sha256").update(seed).digest("hex");
pad += seed;
}
const tarball = createTarball([
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
{ name: "test-package/escape-link", type: "symlink", linkname: escapeTarget },
{ name: "test-package/pad.bin", type: "file", content: pad },
]);
// node:http rather than Bun.serve so the response carries an explicit
// Content-Length *and* can be drip-fed; each write is its own packet so
// the install's HTTP client sees multiple progress callbacks and commits
// to the streaming extractor.
const httpServer = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname.includes("/tarball/")) {
res.setHeader("Content-Type", "application/gzip");
res.setHeader("Content-Length", String(tarball.length));
req.socket.setNoDelay(true);
let offset = 0;
const step = () => {
if (offset >= tarball.length) {
res.end();
return;
}
res.write(Buffer.from(tarball.subarray(offset, Math.min(offset + 1024, tarball.length))));
offset += 1024;
setImmediate(step);
};
step();
return;
}
if (url.pathname.includes("/repos/")) {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ default_branch: "main" }));
return;
}
res.statusCode = 404;
res.end("Not Found");
});
await new Promise<void>(resolve => httpServer.listen(0, "127.0.0.1", () => resolve()));
const port = (httpServer.address() as { port: number }).port;
try {
using dir = tempDir("streaming-symlink-target-test", {});
const installDir = String(dir);
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
);
const proc = spawn({
cmd: [bunExe(), "install", "--verbose"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: {
...env,
GITHUB_API_URL: `http://127.0.0.1:${port}`,
BUN_INSTALL_CACHE_DIR: join(installDir, ".bun-cache"),
// Lower the streaming threshold so this tarball qualifies without
// having to be multiple megabytes.
BUN_INSTALL_STREAMING_MIN_SIZE: "1024",
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Confirm the streaming extractor actually handled this tarball; if the
// buffered fallback ran instead this test would not be exercising the
// streaming symlink validation at all.
expect(stderr).toContain("Streamed ");
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
// No symlink anywhere under the install root (node_modules and the
// package cache included) may point at the escaping target.
const escapingSymlinks: string[] = [];
for (const entry of await readdir(installDir, { recursive: true, withFileTypes: true })) {
if (!entry.isSymbolicLink()) continue;
const linkPath = join(entry.parentPath, entry.name);
const target = await readlink(linkPath);
if (target.includes("escape-target")) {
escapingSymlinks.push(`${linkPath} -> ${target}`);
}
}
expect(escapingSymlinks).toEqual([]);
// The legitimate entries are still extracted.
const pkgDir = join(installDir, "node_modules", "test-package");
await access(join(pkgDir, "package.json"));
await access(join(pkgDir, "pad.bin"));
} finally {
httpServer.closeAllConnections?.();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
}
});
it("should skip symlinks with relative path traversal targets", async () => {
// This reproduces the exact attack from the security report:
// 1. Symlink test-package/symlink-to-tmp -> ../../../../../../../<tmpdir>
// 2. File test-package/symlink-to-tmp/pwned.txt
// Calculate relative path to system temp directory (enough ../ to escape)
const symlinkTarget = "../../../../../../../" + systemTmpDir.replace(/^\//, "");
const tarball = createTarball([
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
// Malicious symlink pointing way outside
{ name: "test-package/symlink-to-tmp", type: "symlink", linkname: symlinkTarget },
// File that would be written through the symlink
{ name: "test-package/symlink-to-tmp/pwned.txt", type: "file", content: "Arbitrary file write" },
]);
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) {
return new Response(tarball, { headers: { "Content-Type": "application/gzip" } });
}
if (url.pathname.includes("/repos/")) {
return Response.json({ default_branch: "main" });
}
return new Response("Not Found", { status: 404 });
},
});
try {
using dir = tempDir("symlink-traversal-test", {});
const installDir = String(dir);
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
);
await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`);
const proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` },
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The install should complete successfully (exit code 0)
// If it fails, show diagnostics
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
// Verify stderr doesn't leak absolute paths like the system temp directory
expect(stderr).not.toContain(systemTmpDir);
// CRITICAL CHECK: Verify no file was written to system temp directory
let fileInTmp = false;
try {
await access(pwnedFilePath);
fileInTmp = true;
} catch {
fileInTmp = false;
}
expect(fileInTmp).toBe(false);
// Verify the malicious symlink was NOT created as a symlink
// (It may exist as a directory since the tarball has a file entry through it)
const pkgDir = join(installDir, "node_modules", "test-package");
const symlinkPath = join(pkgDir, "symlink-to-tmp");
try {
const stats = await lstat(symlinkPath);
// If it exists, it must NOT be a symlink (directory is OK - that's what happens
// when the symlink is blocked but a file tries to write through it)
expect(stats.isSymbolicLink()).toBe(false);
} catch {
// Path doesn't exist at all - also acceptable
}
} finally {
server.stop();
// Clean up pwned file in case the test failed
try {
await rm(pwnedFilePath, { force: true });
} catch {}
}
});
it("should skip symlinks with absolute path targets", async () => {
const tarball = createTarball([
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
// Absolute symlink - directly points to system temp directory
{ name: "test-package/abs-symlink", type: "symlink", linkname: systemTmpDir },
]);
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) {
return new Response(tarball, { headers: { "Content-Type": "application/gzip" } });
}
if (url.pathname.includes("/repos/")) {
return Response.json({ default_branch: "main" });
}
return new Response("Not Found", { status: 404 });
},
});
try {
using dir = tempDir("absolute-symlink-test", {});
const installDir = String(dir);
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
);
await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`);
const proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` },
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The install should complete successfully
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
// Check that no absolute symlink was created
const pkgDir = join(installDir, "node_modules", "test-package");
try {
const symlinkPath = join(pkgDir, "abs-symlink");
const stats = await lstat(symlinkPath);
if (stats.isSymbolicLink()) {
const target = await readlink(symlinkPath);
// Absolute symlinks should be blocked
expect(target.startsWith("/")).toBe(false);
}
} catch {
// Symlink doesn't exist - expected behavior
}
} finally {
server.stop();
}
});
it("should allow safe relative symlinks within the package (install succeeds)", async () => {
// This test verifies that safe symlinks don't cause extraction to fail.
// Note: Safe symlinks ARE created in the cache during extraction, but bun's
// install process doesn't preserve them in the final node_modules.
// We verify the install succeeds, which proves safe symlinks are allowed.
const tarball = createTarball([
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
{ name: "test-package/src/", type: "dir" },
{ name: "test-package/src/index.js", type: "file", content: "module.exports = 'hello';" },
// Safe symlink - points to sibling directory (stays within package)
{ name: "test-package/link-to-src", type: "symlink", linkname: "src" },
// Safe symlink - relative path within same directory
{ name: "test-package/src/link-to-index", type: "symlink", linkname: "./index.js" },
]);
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) {
return new Response(tarball, { headers: { "Content-Type": "application/gzip" } });
}
if (url.pathname.includes("/repos/")) {
return Response.json({ default_branch: "main" });
}
return new Response("Not Found", { status: 404 });
},
});
try {
using dir = tempDir("safe-symlink-test", {});
const installDir = String(dir);
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
);
await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`);
const proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` },
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Install should succeed - safe symlinks should not cause errors
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
// Verify package was installed (package.json should exist)
const pkgDir = join(installDir, "node_modules", "test-package");
const pkgJsonPath = join(pkgDir, "package.json");
await access(pkgJsonPath); // Throws if doesn't exist
} finally {
server.stop();
}
});
for (const mode of ["streaming", "buffered"] as const) {
it(`writes every directory and file entry inside the package root before creating symlink entries whose names differ only by Unicode normalization (${mode} extraction)`, async () => {
let pad = "";
let seed = `deferred-symlink-pad-${mode}`;
while (pad.length < 256 * 1024) {
seed = createHash("sha256").update(seed).digest("hex");
pad += seed;
}
const composed = "d/" + String.fromCharCode(0xe9);
const decomposed = "d/e" + String.fromCharCode(0x301);
const tarball = createTarball([
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
{ name: "test-package/z/", type: "dir" },
{ name: "test-package/q/", type: "dir" },
{ name: `test-package/${composed}`, type: "symlink", linkname: "../q" },
{ name: `test-package/${decomposed}/x`, type: "symlink", linkname: "../../z" },
{ name: "test-package/q/x/marker/", type: "dir" },
{ name: "test-package/q/x/marker/proof.txt", type: "file", content: "stays inside the package" },
{ name: `test-package/${decomposed}/x/nested.txt`, type: "file", content: "written at its literal path" },
{ name: "test-package/pad.bin", type: "file", content: pad },
]);
const httpServer = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname.includes("/tarball/")) {
res.setHeader("Content-Type", "application/gzip");
res.setHeader("Content-Length", String(tarball.length));
req.socket.setNoDelay(true);
let offset = 0;
const step = () => {
if (offset >= tarball.length) {
res.end();
return;
}
res.write(Buffer.from(tarball.subarray(offset, Math.min(offset + 1024, tarball.length))));
offset += 1024;
setImmediate(step);
};
step();
return;
}
if (url.pathname.includes("/repos/")) {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ default_branch: "main" }));
return;
}
res.statusCode = 404;
res.end("Not Found");
});
await new Promise<void>(resolve => httpServer.listen(0, "127.0.0.1", () => resolve()));
const port = (httpServer.address() as { port: number }).port;
try {
using dir = tempDir(`deferred-symlink-${mode}-test`, {
"package.json": JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
});
const installDir = String(dir);
const scratch = join(installDir, ".bun-tmp");
const cache = join(installDir, ".bun-cache");
await mkdir(join(scratch, "z"), { recursive: true });
await mkdir(join(cache, "z"), { recursive: true });
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--verbose"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: {
...env,
GITHUB_API_URL: `http://127.0.0.1:${port}`,
BUN_INSTALL_CACHE_DIR: cache,
BUN_TMPDIR: scratch,
TMPDIR: scratch,
BUN_INSTALL_STREAMING_MIN_SIZE: "1024",
...(mode === "buffered" ? { BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL: "1" } : {}),
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (mode === "streaming") {
expect(stderr).toContain("Streamed ");
} else {
expect(stderr).not.toContain("Streamed ");
}
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
const misplaced: string[] = [];
let markerDirs = 0;
for (const entry of await readdir(installDir, { recursive: true, withFileTypes: true })) {
if (entry.name !== "marker") continue;
const parentStats = await lstat(entry.parentPath);
if (basename(entry.parentPath) === "x" && parentStats.isDirectory() && !parentStats.isSymbolicLink()) {
markerDirs++;
} else {
misplaced.push(join(entry.parentPath, entry.name));
}
}
expect(misplaced).toEqual([]);
expect(markerDirs).toBeGreaterThan(0);
const pkgDir = join(installDir, "node_modules", "test-package");
expect((await lstat(join(pkgDir, "q", "x"))).isSymbolicLink()).toBe(false);
expect(await Bun.file(join(pkgDir, "q", "x", "marker", "proof.txt")).text()).toBe("stays inside the package");
const literalX = await lstat(join(pkgDir, decomposed, "x"));
expect(literalX.isSymbolicLink()).toBe(false);
expect(literalX.isDirectory()).toBe(true);
expect(await Bun.file(join(pkgDir, decomposed, "x", "nested.txt")).text()).toBe("written at its literal path");
} finally {
httpServer.closeAllConnections?.();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
}
});
}
});
it.skipIf(isWindows)(
"rejects symlink targets that climb through other symlinks from the same archive (.bun-tag write stays inside the package)",
async () => {
// A tarball can ship symlinks whose targets each normalize to a path
// inside the package (`l1 -> .`, `l2 -> l1/..`, `l3 -> l2/..`, ...).
// Lexically every hop is "safe", but the kernel resolves each link before
// applying `..`, so each hop climbs one directory until it clamps at `/`.
// A final `.bun-tag -> lN/<absolute path minus leading slash>` entry then
// makes the post-extraction `.bun-tag` marker write (O_CREAT|O_TRUNC) land
// on an arbitrary file. The extractor must reject symlink targets with a
// `..` component that follows a named component, and must not follow a
// pre-existing `.bun-tag` when writing the marker.
const victimPath = join(systemTmpDir, `bun-tag-victim-${Math.random().toString(36).slice(2, 10)}.txt`);
await writeFile(victimPath, "original-content");
const chainLength = 30;
const entries: Parameters<typeof createTarball>[0] = [
{ name: "test-package/", type: "dir" },
{
name: "test-package/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
{ name: "test-package/l1", type: "symlink", linkname: "." },
];
for (let i = 2; i <= chainLength; i++) {
// Normalizes to "" (inside the package), but resolves one directory
// above wherever l(i-1) resolves to.
entries.push({ name: `test-package/l${i}`, type: "symlink", linkname: `l${i - 1}/..` });
}
// After `chainLength` hops the chain is clamped at `/`, so this resolves to
// the absolute victim path while still normalizing to a path inside the
// package directory.
entries.push({
name: "test-package/.bun-tag",
type: "symlink",
linkname: `l${chainLength}/${victimPath.replace(/^\/+/, "")}`,
});
const tarball = createTarball(entries);
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) {
return new Response(tarball, { headers: { "Content-Type": "application/gzip" } });
}
if (url.pathname.includes("/repos/")) {
return Response.json({ default_branch: "main" });
}
return new Response("Not Found", { status: 404 });
},
});
try {
using dir = tempDir("bun-tag-symlink-chain-test", {});
const installDir = String(dir);
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
);
const proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: {
...env,
GITHUB_API_URL: `http://localhost:${server.port}`,
BUN_INSTALL_CACHE_DIR: join(installDir, ".bun-cache"),
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The file outside the extraction directory must be untouched: same
// content, not truncated, not replaced with the github tag string.
expect(await Bun.file(victimPath).text()).toBe("original-content");
// The legitimate package contents are still installed.
const pkgDir = join(installDir, "node_modules", "test-package");
await access(join(pkgDir, "package.json"));
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
} finally {
server.stop();
await rm(victimPath, { force: true });
}
},
60000,
);
it.skipIf(isWindows)(
"does not change permissions of a file reached through a symlinked bin target",
async () => {
// After creating `node_modules/.bin/<name>`, the installer chmods the bin
// target to make it executable. If the bin target inside the package is
// itself a symlink (git/file/workspace dependencies can ship one — the npm
// tarball extractor never materializes one), chmod follows it and changes
// the mode of whatever file it points at, including files outside
// node_modules. The chmod must be skipped when the bin target is a symlink.
using dir = tempDir("bin-target-symlink-test", {
// Pin the hoisted linker so the bin link lands at node_modules/.bin and
// the chmod runs against the package's own bin target.
"bunfig.toml": `[install]\nlinker = "hoisted"\n`,
"package.json": JSON.stringify({
name: "bin-target-symlink-app",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/dep/package.json": JSON.stringify({
name: "dep-with-symlinked-bin",
version: "1.0.0",
bin: { "dep-with-symlinked-bin": "./payload" },
}),
"victim.txt": "do not make me executable",
});
const installDir = String(dir);
const victimPath = join(installDir, "victim.txt");
await chmod(victimPath, 0o600);
// The bin target is a symlink whose destination is outside the package
// directory.
await symlink(join("..", "..", "victim.txt"), join(installDir, "packages", "dep", "payload"));
const proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The file the symlink points at keeps its original permissions; the
// installer must not chmod through a symlinked bin target.
expect((await stat(victimPath)).mode & 0o777).toBe(0o600);
// The bin link itself is still created.
const binLink = join(installDir, "node_modules", ".bin", "dep-with-symlinked-bin");
expect((await lstat(binLink)).isSymbolicLink()).toBe(true);
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
},
60000,
);
it.skipIf(isWindows)(
"does not change permissions of a directory reached through a symlinked bin target with a trailing slash",
async () => {
// Same as above, but the bin target is written as "payload/". The kernel
// follows a symlink named with a trailing slash even when chmod is told not
// to follow symlinks, so the installer has to drop the slash before it
// stats, links and chmods the target.
using dir = tempDir("bin-target-symlink-trailing-slash-test", {
"bunfig.toml": `[install]\nlinker = "hoisted"\n`,
"package.json": JSON.stringify({
name: "bin-target-symlink-slash-app",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/dep/package.json": JSON.stringify({
name: "dep-with-symlinked-dir-bin",
version: "1.0.0",
bin: { "dep-with-symlinked-dir-bin": "./payload/" },
}),
"victim-dir/keep": "",
});
const installDir = String(dir);
const victimDir = join(installDir, "victim-dir");
await chmod(victimDir, 0o700);
await symlink(join("..", "..", "victim-dir"), join(installDir, "packages", "dep", "payload"));
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect((await stat(victimDir)).mode & 0o777).toBe(0o700);
// Without the slash this target links (see above), so with it the result is the same.
const binLink = join(installDir, "node_modules", ".bin", "dep-with-symlinked-dir-bin");
expect(await readlink(binLink)).toBe(join("..", "dep-with-symlinked-dir-bin", "payload"));
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
},
60000,
);
it.skipIf(isWindows)(
"skips a package bin entry whose name contains a NUL byte and links the remaining entries",
async () => {
using dir = tempDir("bin-name-nul-test", {
"bunfig.toml": `[install]\nlinker = "hoisted"\n`,
"package.json": JSON.stringify({
name: "bin-name-nul-app",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/dep/package.json": JSON.stringify({
name: "dep-with-nul-bin",
version: "1.0.0",
bin: { ["extra" + String.fromCharCode(0) + "ignoredtail"]: "./cli.js", "good-bin": "./cli.js" },
}),
"packages/dep/cli.js": `#!/usr/bin/env node\nconsole.log("ok");\n`,
});
const installDir = String(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect((await readdir(join(installDir, "node_modules", ".bin"))).sort()).toEqual(["good-bin"]);
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
},
60000,
);
it.skipIf(isWindows)(
"does not link a bin target that resolves outside the package through a symlinked directory",
async () => {
using dir = tempDir("bin-target-symlinked-dir-test", {
"bunfig.toml": `[install]\nlinker = "hoisted"\n`,
"package.json": JSON.stringify({
name: "bin-target-dir-app",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/dep/package.json": JSON.stringify({
name: "dep-with-linked-dir-bin",
version: "1.0.0",
bin: { "linked-dir-tool": "lnk/tool.js" },
}),
});
const installDir = await realpath(String(dir));
const outsideDir = `${installDir}/abcdefghijkl${installDir}/packages/dep/y`;
await mkdir(outsideDir, { recursive: true });
const toolPath = join(outsideDir, "tool.js");
await writeFile(toolPath, `#!/usr/bin/env node\nconsole.log("ok");\n`);
await chmod(toolPath, 0o600);
await symlink(outsideDir, join(installDir, "packages", "dep", "lnk"));
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect((await stat(toolPath)).mode & 0o777).toBe(0o600);
expect(await readdir(join(installDir, "node_modules", ".bin")).catch(() => [])).toEqual([]);
if (exitCode !== 0) {
console.error("Install failed with exit code:", exitCode);
console.error("stdout:", stdout);
console.error("stderr:", stderr);
}
expect(exitCode).toBe(0);
},
60000,
);
it.skipIf(isWindows)(
"rejects a GitHub tarball whose root directory name contains a path separator",
async () => {
const tarball = createTarball([
{ name: "pkg.root/extra/", type: "dir" },
{
name: "pkg.root/package.json",
type: "file",
content: JSON.stringify({ name: "test-package", version: "1.0.0" }),
},
{ name: "pkg.root/index.js", type: "file", content: "module.exports = 1;" },
]);
using server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) {
return new Response(tarball, { headers: { "Content-Type": "application/gzip" } });
}
if (url.pathname.includes("/repos/")) {
return Response.json({ default_branch: "main" });
}
return new Response("Not Found", { status: 404 });
},
});
using dir = tempDir("github-tarball-root-name-test", {
"package.json": JSON.stringify({
name: "test-app",
version: "1.0.0",
dependencies: { "test-package": "github:user/repo#main" },
}),
});
const installDir = String(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: installDir,
stdout: "pipe",
stderr: "pipe",
env: {
...env,
GITHUB_API_URL: `http://localhost:${server.port}`,
BUN_INSTALL_CACHE_DIR: join(installDir, ".bun-cache"),
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain('tarball root directory "pkg.root/extra" is not a valid folder name');
expect(stdout).not.toContain("1 package installed");
expect(exitCode).not.toBe(0);
},
60000,
);