Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
acd956ae53 fix: use strings.eql for SIMD-accelerated comparison and add unlink traversal test
- Replace std.mem.eql with strings.eql in isTargetWithinPackageDir for
  consistent use of bun's SIMD-accelerated string comparison on Linux
- Add test verifying directories.bin with traversal paths doesn't unlink
  external files during package removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-28 09:21:54 +00:00
Claude Bot
0058400399 fix(install): normalize bin target paths to stay within package directory
Harden bin linking to normalize bin target values from package.json
before resolving them, matching npm's npm-normalize-package-bin behavior.
Previously, bin target values were used as-is with path.join semantics,
which could resolve outside the intended package directory. Now targets
are normalized (stripping leading slashes and resolving .. segments) and
validated to ensure the resolved path stays within the package directory.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-27 05:50:20 +00:00
2 changed files with 394 additions and 5 deletions

View File

@@ -501,6 +501,45 @@ pub const Bin = extern struct {
return name;
}
/// Normalize a bin target value (the file path the bin points to) to ensure
/// it stays within the package directory. Matches npm's npm-normalize-package-bin
/// behavior: `path.join('/', target).slice(1)` which strips leading slashes
/// and resolves `..` segments so the target cannot escape the package root.
/// https://github.com/npm/npm-normalize-package-bin/blob/574e6d7cd21b2f3dee28a216ec2053c2551f7af9/lib/index.js#L34
pub fn normalizedBinTarget(target: []const u8, buf: []u8) ?[]const u8 {
if (target.len == 0) return null;
// Normalize: resolve `.` and `..` segments with allow_above_root=false
// (equivalent to npm's `path.join('/', target).slice(1)`)
const normalized = path.normalizeStringBuf(target, buf, false, .auto, false);
if (normalized.len == 0) return null;
// Strip any leading separator (makes absolute paths relative, matching npm's .slice(1))
var result = normalized;
while (result.len > 0 and path.isSepAny(result[0])) {
result = result[1..];
}
if (result.len == 0) return null;
return result;
}
/// Check that a resolved absolute target path is contained within the
/// expected package directory. Returns true if abs_target starts with
/// package_dir followed by a path separator.
fn isTargetWithinPackageDir(package_dir: []const u8, abs_target: []const u8) bool {
const dir = strings.withoutTrailingSlash(package_dir);
if (abs_target.len <= dir.len) return false;
const prefix = abs_target[0..dir.len];
const matches = if (comptime !bun.Environment.isLinux)
strings.eqlCaseInsensitiveASCII(prefix, dir, false)
else
strings.eql(prefix, dir);
return matches and path.isSepAny(abs_target[dir.len]);
}
pub const Linker = struct {
bin: Bin,
@@ -901,8 +940,13 @@ pub const Bin = extern struct {
const target = this.bin.value.file.slice(this.string_buf);
if (target.len == 0) return;
var normalize_buf: bun.PathBuffer = undefined;
const normalized_target = normalizedBinTarget(target, &normalize_buf) orelse return;
// for normalizing `target`
const abs_target = path.joinAbsStringZ(package_dir, &.{target}, .auto);
const abs_target = path.joinAbsStringZ(package_dir, &.{normalized_target}, .auto);
if (!isTargetWithinPackageDir(package_dir, abs_target)) return;
const unscoped_package_name = Dependency.unscopedPackageName(this.package_name.slice());
@memcpy(abs_dest_buf_remain[0..unscoped_package_name.len], unscoped_package_name);
@@ -919,8 +963,13 @@ pub const Bin = extern struct {
const target = this.bin.value.named_file[1].slice(this.string_buf);
if (normalized_name.len == 0 or target.len == 0) return;
var normalize_buf: bun.PathBuffer = undefined;
const normalized_target = normalizedBinTarget(target, &normalize_buf) orelse return;
// for normalizing `target`
const abs_target = path.joinAbsStringZ(package_dir, &.{target}, .auto);
const abs_target = path.joinAbsStringZ(package_dir, &.{normalized_target}, .auto);
if (!isTargetWithinPackageDir(package_dir, abs_target)) return;
@memcpy(abs_dest_buf_remain[0..normalized_name.len], normalized_name);
abs_dest_buf_remain = abs_dest_buf_remain[normalized_name.len..];
@@ -942,7 +991,12 @@ pub const Bin = extern struct {
const bin_target = this.extern_string_buf[i + 1].slice(this.string_buf);
if (bin_target.len == 0 or normalized_bin_dest.len == 0) continue;
const abs_target = path.joinAbsStringZ(package_dir, &.{bin_target}, .auto);
var normalize_buf: bun.PathBuffer = undefined;
const normalized_bin_target = normalizedBinTarget(bin_target, &normalize_buf) orelse continue;
const abs_target = path.joinAbsStringZ(package_dir, &.{normalized_bin_target}, .auto);
if (!isTargetWithinPackageDir(package_dir, abs_target)) continue;
abs_dest_buf_remain = abs_dest_dir_end;
@memcpy(abs_dest_buf_remain[0..normalized_bin_dest.len], normalized_bin_dest);
@@ -958,8 +1012,13 @@ pub const Bin = extern struct {
const target = this.bin.value.dir.slice(this.string_buf);
if (target.len == 0) return;
var normalize_buf: bun.PathBuffer = undefined;
const normalized_target = normalizedBinTarget(target, &normalize_buf) orelse return;
// for normalizing `target`
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{target}, .auto);
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{normalized_target}, .auto);
if (!isTargetWithinPackageDir(package_dir, abs_target_dir)) return;
var target_dir = bun.openDirAbsolute(abs_target_dir) catch |err| {
if (err == error.ENOENT) {
@@ -1053,7 +1112,12 @@ pub const Bin = extern struct {
const target = this.bin.value.dir.slice(this.string_buf);
if (target.len == 0) return;
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{target}, .auto);
var normalize_buf: bun.PathBuffer = undefined;
const normalized_target = normalizedBinTarget(target, &normalize_buf) orelse return;
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{normalized_target}, .auto);
if (!isTargetWithinPackageDir(package_dir, abs_target_dir)) return;
var target_dir = bun.openDirAbsolute(abs_target_dir) catch |err| {
this.err = err;

View File

@@ -0,0 +1,325 @@
import { spawn } from "bun";
import { describe, expect, it } from "bun:test";
import { exists, stat } from "fs/promises";
import { bunEnv, bunExe, isWindows, readdirSorted, tempDir, toBeValidBin, toHaveBins } from "harness";
import { join } from "path";
expect.extend({
toBeValidBin,
toHaveBins,
});
describe("bin target normalization", () => {
it("should not link bin targets with parent directory traversal", async () => {
// A package with a bin target using "../" that would escape the package directory.
// After normalization, the traversal components are stripped so the bin won't
// resolve to a file outside the package.
using dir = tempDir("bin-target-norm", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"bad-pkg": "file:./bad-pkg",
},
}),
"bad-pkg/package.json": JSON.stringify({
name: "bad-pkg",
version: "1.0.0",
bin: {
"bad-bin": "../../escape-target.js",
},
}),
"bad-pkg/index.js": "#!/usr/bin/env node\nconsole.log('ok');",
// This file exists at the traversal target but should NOT be linked
"escape-target.js": "#!/usr/bin/env node\nconsole.log('escaped');",
});
// Record the permissions of the escape target before install
const escapeTargetPath = join(String(dir), "escape-target.js");
const statBefore = await stat(escapeTargetPath);
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// The .bin directory should not exist or should not contain bad-bin,
// because the normalized target doesn't exist within the package dir
const binDir = join(String(dir), "node_modules", ".bin");
const binDirExists = await exists(binDir);
if (binDirExists) {
const bins = await readdirSorted(binDir);
if (!isWindows) {
expect(bins).not.toContain("bad-bin");
} else {
expect(bins).not.toContain("bad-bin.exe");
}
}
// The file at the traversal path should not have had its permissions changed
const statAfter = await stat(escapeTargetPath);
expect(statAfter.mode).toBe(statBefore.mode);
});
it("should not link bin targets with absolute paths", async () => {
// A package with an absolute path as a bin target.
// The absolute path is normalized to a relative path within the package,
// which won't match any existing file.
using dir = tempDir("bin-target-abs", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"abs-pkg": "file:./abs-pkg",
},
}),
"abs-pkg/package.json": JSON.stringify({
name: "abs-pkg",
version: "1.0.0",
bin: {
"abs-bin": "/etc/passwd",
},
}),
"abs-pkg/index.js": "module.exports = {};",
});
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// No bin should be created since /etc/passwd normalizes to etc/passwd
// which doesn't exist within the package directory
const binDir = join(String(dir), "node_modules", ".bin");
const binDirExists = await exists(binDir);
if (binDirExists) {
const bins = await readdirSorted(binDir);
if (!isWindows) {
expect(bins).not.toContain("abs-bin");
} else {
expect(bins).not.toContain("abs-bin.exe");
}
}
});
it("should allow valid relative bin targets within the package", async () => {
// Normal case: bin target is a valid relative path within the package.
// This should continue to work correctly.
using dir = tempDir("bin-target-valid", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"good-pkg": "file:./good-pkg",
},
}),
"good-pkg/package.json": JSON.stringify({
name: "good-pkg",
version: "1.0.0",
bin: {
"good-bin": "./bin/cli.js",
},
}),
"good-pkg/bin/cli.js": "#!/usr/bin/env node\nconsole.log('hello');",
});
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// The bin should be correctly linked
const binDir = join(String(dir), "node_modules", ".bin");
expect(await exists(binDir)).toBe(true);
expect(await readdirSorted(binDir)).toHaveBins(["good-bin"]);
expect(join(binDir, "good-bin")).toBeValidBin(join("..", "good-pkg", "bin", "cli.js"));
});
it("should normalize bin map targets with traversal paths", async () => {
// Test the map case with multiple bins: valid bins should be linked,
// while bins with traversal paths should be skipped.
using dir = tempDir("bin-target-map", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"map-pkg": "file:./map-pkg",
},
}),
"map-pkg/package.json": JSON.stringify({
name: "map-pkg",
version: "1.0.0",
bin: {
"good-cmd": "./bin/good.js",
"bad-cmd": "../../../etc/shadow",
"also-good": "lib/index.js",
},
}),
"map-pkg/bin/good.js": "#!/usr/bin/env node\nconsole.log('good');",
"map-pkg/lib/index.js": "#!/usr/bin/env node\nconsole.log('also good');",
});
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Good bins should be linked correctly
const binDir = join(String(dir), "node_modules", ".bin");
expect(await exists(binDir)).toBe(true);
const bins = await readdirSorted(binDir);
expect(bins).toHaveBins(["also-good", "good-cmd"]);
expect(join(binDir, "good-cmd")).toBeValidBin(join("..", "map-pkg", "bin", "good.js"));
expect(join(binDir, "also-good")).toBeValidBin(join("..", "map-pkg", "lib", "index.js"));
// bad-cmd should NOT be in the bin directory
if (!isWindows) {
expect(bins).not.toContain("bad-cmd");
} else {
expect(bins).not.toContain("bad-cmd.exe");
}
});
it("should not unlink directory-mode bin targets with traversal paths", async () => {
// Create a temp directory structure where a dependency uses directories.bin
// with a traversal path. After install + remove, the external directory
// and its files must remain untouched.
using dir = tempDir("bin-target-dir-unlink", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"dir-bin-pkg": "file:./dir-bin-pkg",
},
}),
"dir-bin-pkg/package.json": JSON.stringify({
name: "dir-bin-pkg",
version: "1.0.0",
directories: {
bin: "../../escape-dir",
},
}),
"dir-bin-pkg/index.js": "module.exports = {};",
// Create the external directory that the traversal would target
"escape-dir/some-script.js": "#!/usr/bin/env node\nconsole.log('external');",
});
const escapeDirPath = join(String(dir), "escape-dir");
const escapeFilePath = join(String(dir), "escape-dir", "some-script.js");
// Verify the escape target exists before install
expect(await exists(escapeFilePath)).toBe(true);
const statBefore = await stat(escapeFilePath);
// Run install
{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
}
// Now remove the dependency (triggers unlink path)
{
await using proc = spawn({
cmd: [bunExe(), "remove", "dir-bin-pkg"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
}
// The external directory and file must still exist and be unchanged
expect(await exists(escapeDirPath)).toBe(true);
expect(await exists(escapeFilePath)).toBe(true);
const statAfter = await stat(escapeFilePath);
expect(statAfter.mode).toBe(statBefore.mode);
expect(statAfter.size).toBe(statBefore.size);
});
it("should normalize bin named_file target with traversal", async () => {
// Test a package where the single "bin" field is a string (named_file case)
// with a traversal path
using dir = tempDir("bin-target-named", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"named-pkg": "file:./named-pkg",
},
}),
"named-pkg/package.json": JSON.stringify({
name: "named-pkg",
version: "1.0.0",
bin: "../../etc/shadow",
}),
"named-pkg/index.js": "module.exports = {};",
});
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// No bin should be created
const binDir = join(String(dir), "node_modules", ".bin");
const binDirExists = await exists(binDir);
if (binDirExists) {
const bins = await readdirSorted(binDir);
if (!isWindows) {
expect(bins).not.toContain("named-pkg");
} else {
expect(bins).not.toContain("named-pkg.exe");
}
}
});
});