mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
fix(install): only apply default trusted dependencies to npm packages (#25163)
## Summary - The default trusted dependencies list should only apply to packages installed from npm - Non-npm sources (file:, link:, git:, github:) now require explicit trustedDependencies - This prevents malicious packages from spoofing trusted names through local paths or git repos ## Test plan - [x] Added test: file: dependency named "esbuild" does NOT auto-run postinstall scripts - [x] Added test: file: dependency runs scripts when explicitly added to trustedDependencies - [x] Verified tests fail with system bun (old behavior) and pass with new build - [x] Build compiles successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com> Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
@@ -8,7 +8,9 @@ Unlike other npm clients, Bun does not execute arbitrary lifecycle scripts for i
|
|||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Bun includes a default allowlist of popular packages containing `postinstall` scripts that are known to be safe. You
|
Bun includes a default allowlist of popular packages containing `postinstall` scripts that are known to be safe. You
|
||||||
can see this list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt).
|
can see this list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt). This
|
||||||
|
default list only applies to packages installed from npm. For packages from other sources (such as `file:`, `link:`,
|
||||||
|
`git:`, or `github:` dependencies), you must explicitly add them to `trustedDependencies`.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ Once added to `trustedDependencies`, install/re-install the package. Bun will re
|
|||||||
|
|
||||||
The top 500 npm packages with lifecycle scripts are allowed by default. You can see the full list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt).
|
The top 500 npm packages with lifecycle scripts are allowed by default. You can see the full list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt).
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The default trusted dependencies list only applies to packages installed from npm. For packages from other sources
|
||||||
|
(such as `file:`, `link:`, `git:`, or `github:` dependencies), you must explicitly add them to `trustedDependencies`
|
||||||
|
to run their lifecycle scripts, even if the package name matches an entry in the default list. This prevents malicious
|
||||||
|
packages from spoofing trusted package names through local file paths or git repositories.
|
||||||
|
</Note>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `--ignore-scripts`
|
## `--ignore-scripts`
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ pub const UntrustedCommand = struct {
|
|||||||
|
|
||||||
// called alias because a dependency name is not always the package name
|
// called alias because a dependency name is not always the package name
|
||||||
const alias = dep.name.slice(buf);
|
const alias = dep.name.slice(buf);
|
||||||
|
const resolution = &resolutions[package_id];
|
||||||
if (!pm.lockfile.hasTrustedDependency(alias)) {
|
if (!pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||||
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,8 +191,8 @@ pub const TrustCommand = struct {
|
|||||||
if (package_id == Install.invalid_package_id) continue;
|
if (package_id == Install.invalid_package_id) continue;
|
||||||
|
|
||||||
const alias = dep.name.slice(buf);
|
const alias = dep.name.slice(buf);
|
||||||
|
const resolution = &resolutions[package_id];
|
||||||
if (!pm.lockfile.hasTrustedDependency(alias)) {
|
if (!pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||||
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ pub const TrustCommand = struct {
|
|||||||
if (trust_all) break :brk false;
|
if (trust_all) break :brk false;
|
||||||
|
|
||||||
for (packages_to_trust.items) |package_name_from_cli| {
|
for (packages_to_trust.items) |package_name_from_cli| {
|
||||||
if (strings.eqlLong(package_name_from_cli, alias, true) and !pm.lockfile.hasTrustedDependency(alias)) {
|
if (strings.eqlLong(package_name_from_cli, alias, true) and !pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||||
break :brk false;
|
break :brk false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,7 +1152,7 @@ pub const PackageInstaller = struct {
|
|||||||
const truncated_dep_name_hash: TruncatedPackageNameHash = @truncate(dep.name_hash);
|
const truncated_dep_name_hash: TruncatedPackageNameHash = @truncate(dep.name_hash);
|
||||||
const is_trusted, const is_trusted_through_update_request = brk: {
|
const is_trusted, const is_trusted_through_update_request = brk: {
|
||||||
if (this.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) break :brk .{ true, true };
|
if (this.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) break :brk .{ true, true };
|
||||||
if (this.lockfile.hasTrustedDependency(alias.slice(this.lockfile.buffers.string_bytes.items))) break :brk .{ true, false };
|
if (this.lockfile.hasTrustedDependency(alias.slice(this.lockfile.buffers.string_bytes.items), resolution)) break :brk .{ true, false };
|
||||||
break :brk .{ false, false };
|
break :brk .{ false, false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -916,7 +916,7 @@ pub const Installer = struct {
|
|||||||
if (installer.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) {
|
if (installer.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) {
|
||||||
break :brk .{ true, true };
|
break :brk .{ true, true };
|
||||||
}
|
}
|
||||||
if (installer.lockfile.hasTrustedDependency(dep.name.slice(string_buf))) {
|
if (installer.lockfile.hasTrustedDependency(dep.name.slice(string_buf), &pkg_res)) {
|
||||||
break :brk .{ true, false };
|
break :brk .{ true, false };
|
||||||
}
|
}
|
||||||
break :brk .{ false, false };
|
break :brk .{ false, false };
|
||||||
|
|||||||
@@ -2119,13 +2119,14 @@ pub const default_trusted_dependencies = brk: {
|
|||||||
break :brk &final;
|
break :brk &final;
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn hasTrustedDependency(this: *const Lockfile, name: []const u8) bool {
|
pub fn hasTrustedDependency(this: *const Lockfile, name: []const u8, resolution: *const Resolution) bool {
|
||||||
if (this.trusted_dependencies) |trusted_dependencies| {
|
if (this.trusted_dependencies) |trusted_dependencies| {
|
||||||
const hash = @as(u32, @truncate(String.Builder.stringHash(name)));
|
const hash = @as(u32, @truncate(String.Builder.stringHash(name)));
|
||||||
return trusted_dependencies.contains(hash);
|
return trusted_dependencies.contains(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
return default_trusted_dependencies.has(name);
|
// Only allow default trusted dependencies for npm packages
|
||||||
|
return resolution.tag == .npm and default_trusted_dependencies.has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const NameHashMap = std.ArrayHashMapUnmanaged(PackageNameHash, String, ArrayIdentityContext.U64, false);
|
pub const NameHashMap = std.ArrayHashMapUnmanaged(PackageNameHash, String, ArrayIdentityContext.U64, false);
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ pub const Scripts = extern struct {
|
|||||||
resolution: *const Resolution,
|
resolution: *const Resolution,
|
||||||
) !?Package.Scripts.List {
|
) !?Package.Scripts.List {
|
||||||
if (this.hasAny()) {
|
if (this.hasAny()) {
|
||||||
const add_node_gyp_rebuild_script = if (lockfile.hasTrustedDependency(folder_name) and
|
const add_node_gyp_rebuild_script = if (lockfile.hasTrustedDependency(folder_name, resolution) and
|
||||||
this.install.isEmpty() and
|
this.install.isEmpty() and
|
||||||
this.preinstall.isEmpty())
|
this.preinstall.isEmpty())
|
||||||
brk: {
|
brk: {
|
||||||
|
|||||||
@@ -1760,6 +1760,127 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) {
|
|||||||
expect(await exists(join(packageDir, "node_modules", "electron", "preinstall.txt"))).toBeFalse();
|
expect(await exists(join(packageDir, "node_modules", "electron", "preinstall.txt"))).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default trusted dependencies should only apply to npm packages, not file: dependencies", async () => {
|
||||||
|
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||||
|
|
||||||
|
// Create a file: dependency named "esbuild" (which is in the default trusted dependencies list)
|
||||||
|
// with a postinstall script that would fail if it ran
|
||||||
|
const esbuildPath = join(packageDir, "local-esbuild");
|
||||||
|
await mkdir(esbuildPath, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(esbuildPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "esbuild",
|
||||||
|
version: "1.0.0",
|
||||||
|
scripts: {
|
||||||
|
postinstall: "exit 1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
packageJson,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "foo",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: {
|
||||||
|
// file: dependency named "esbuild" - should NOT use default trusted list
|
||||||
|
esbuild: "file:./local-esbuild",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, stderr, exited } = spawn({
|
||||||
|
cmd: [bunExe(), "install"],
|
||||||
|
cwd: packageDir,
|
||||||
|
stdout: "pipe",
|
||||||
|
stdin: "ignore",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: testEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = await stderr.text();
|
||||||
|
const out = await stdout.text();
|
||||||
|
|
||||||
|
// The install should succeed because the postinstall script should NOT run
|
||||||
|
// (file: dependencies don't use default trusted list, even if name matches)
|
||||||
|
// The postinstall is blocked (not trusted), so we expect the "Blocked" message
|
||||||
|
expect(err).toContain("Saved lockfile");
|
||||||
|
expect(err).not.toContain("not found");
|
||||||
|
expect(err).not.toContain("error:");
|
||||||
|
expect(out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/)).toEqual([
|
||||||
|
expect.stringContaining("bun install v1."),
|
||||||
|
"",
|
||||||
|
"+ esbuild@local-esbuild",
|
||||||
|
"",
|
||||||
|
"1 package installed",
|
||||||
|
"",
|
||||||
|
"Blocked 1 postinstall. Run `bun pm untrusted` for details.",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
expect(await exited).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("file: dependency with default trusted name should run scripts when explicitly added to trustedDependencies", async () => {
|
||||||
|
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||||
|
|
||||||
|
// Create a file: dependency named "esbuild" with a postinstall script that creates a marker file
|
||||||
|
const esbuildPath = join(packageDir, "local-esbuild");
|
||||||
|
await mkdir(esbuildPath, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(esbuildPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "esbuild",
|
||||||
|
version: "1.0.0",
|
||||||
|
scripts: {
|
||||||
|
postinstall: `${bunExe()} -e "require('fs').writeFileSync('postinstall-ran.txt', 'ran')"`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
packageJson,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "foo",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: {
|
||||||
|
esbuild: "file:./local-esbuild",
|
||||||
|
},
|
||||||
|
// Explicitly trust the file: dependency
|
||||||
|
trustedDependencies: ["esbuild"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, stderr, exited } = spawn({
|
||||||
|
cmd: [bunExe(), "install"],
|
||||||
|
cwd: packageDir,
|
||||||
|
stdout: "pipe",
|
||||||
|
stdin: "ignore",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: testEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = await stderr.text();
|
||||||
|
const out = await stdout.text();
|
||||||
|
|
||||||
|
expect(err).toContain("Saved lockfile");
|
||||||
|
expect(err).not.toContain("not found");
|
||||||
|
expect(err).not.toContain("error:");
|
||||||
|
expect(out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/)).toEqual([
|
||||||
|
expect.stringContaining("bun install v1."),
|
||||||
|
"",
|
||||||
|
"+ esbuild@local-esbuild",
|
||||||
|
"",
|
||||||
|
"1 package installed",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
expect(out).not.toContain("Blocked");
|
||||||
|
expect(await exited).toBe(0);
|
||||||
|
|
||||||
|
// The postinstall script should have run because we explicitly trusted it
|
||||||
|
expect(await exists(join(packageDir, "node_modules", "esbuild", "postinstall-ran.txt"))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
test("will run default trustedDependencies after install that didn't include them", async () => {
|
test("will run default trustedDependencies after install that didn't include them", async () => {
|
||||||
await verdaccio.writeBunfig(packageDir, { saveTextLockfile: false, linker: "hoisted" });
|
await verdaccio.writeBunfig(packageDir, { saveTextLockfile: false, linker: "hoisted" });
|
||||||
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||||
|
|||||||
Reference in New Issue
Block a user