diff --git a/docs/guides/install/trusted.mdx b/docs/guides/install/trusted.mdx index 7660315003..191255be09 100644 --- a/docs/guides/install/trusted.mdx +++ b/docs/guides/install/trusted.mdx @@ -8,7 +8,9 @@ Unlike other npm clients, Bun does not execute arbitrary lifecycle scripts for i 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`. --- diff --git a/docs/pm/lifecycle.mdx b/docs/pm/lifecycle.mdx index 0fafcb9b27..4cc4590d1e 100644 --- a/docs/pm/lifecycle.mdx +++ b/docs/pm/lifecycle.mdx @@ -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 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. + + --- ## `--ignore-scripts` diff --git a/src/cli/pm_trusted_command.zig b/src/cli/pm_trusted_command.zig index 817f724516..32883c7cca 100644 --- a/src/cli/pm_trusted_command.zig +++ b/src/cli/pm_trusted_command.zig @@ -37,8 +37,8 @@ pub const UntrustedCommand = struct { // called alias because a dependency name is not always the package name const alias = dep.name.slice(buf); - - if (!pm.lockfile.hasTrustedDependency(alias)) { + const resolution = &resolutions[package_id]; + if (!pm.lockfile.hasTrustedDependency(alias, resolution)) { 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; const alias = dep.name.slice(buf); - - if (!pm.lockfile.hasTrustedDependency(alias)) { + const resolution = &resolutions[package_id]; + if (!pm.lockfile.hasTrustedDependency(alias, resolution)) { try untrusted_dep_ids.put(ctx.allocator, dep_id, {}); } } @@ -263,7 +263,7 @@ pub const TrustCommand = struct { if (trust_all) break :brk false; 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; } } diff --git a/src/install/PackageInstaller.zig b/src/install/PackageInstaller.zig index fabda13fa2..7fec486868 100644 --- a/src/install/PackageInstaller.zig +++ b/src/install/PackageInstaller.zig @@ -1152,7 +1152,7 @@ pub const PackageInstaller = struct { const truncated_dep_name_hash: TruncatedPackageNameHash = @truncate(dep.name_hash); 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.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 }; }; diff --git a/src/install/isolated_install/Installer.zig b/src/install/isolated_install/Installer.zig index 9eadd190c1..3012987caf 100644 --- a/src/install/isolated_install/Installer.zig +++ b/src/install/isolated_install/Installer.zig @@ -916,7 +916,7 @@ pub const Installer = struct { if (installer.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) { 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 .{ false, false }; diff --git a/src/install/lockfile.zig b/src/install/lockfile.zig index 98aef54174..2affea5dee 100644 --- a/src/install/lockfile.zig +++ b/src/install/lockfile.zig @@ -2119,13 +2119,14 @@ pub const default_trusted_dependencies = brk: { 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| { const hash = @as(u32, @truncate(String.Builder.stringHash(name))); 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); diff --git a/src/install/lockfile/Package/Scripts.zig b/src/install/lockfile/Package/Scripts.zig index 3c7a19eb62..f2dadd02b4 100644 --- a/src/install/lockfile/Package/Scripts.zig +++ b/src/install/lockfile/Package/Scripts.zig @@ -259,7 +259,7 @@ pub const Scripts = extern struct { resolution: *const Resolution, ) !?Package.Scripts.List { 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.preinstall.isEmpty()) brk: { diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index d885142b20..b9afbf776c 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -1760,6 +1760,127 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { 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 () => { await verdaccio.writeBunfig(packageDir, { saveTextLockfile: false, linker: "hoisted" }); const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;