Skip to content

resolver: skip auto-install for invalid npm package names#29255

Open
robobun wants to merge 2 commits intomainfrom
farm/c3283de5/resolver-skip-autoinstall-invalid-name
Open

resolver: skip auto-install for invalid npm package names#29255
robobun wants to merge 2 commits intomainfrom
farm/c3283de5/resolver-skip-autoinstall-invalid-name

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 13, 2026

Problem

Fuzzilli hit a flaky SIGSEGV (fingerprint 2519cad1804eace1) from:

const v13 = Bun.jest().vi;
try { v13.mock("function f2() {\n    const v6 = new ArrayBuffer();\n    ...\n}"); } catch (e) {}
Bun.gc(true);

JSMock__jsModuleMock calls Bun__resolveSyncWithSource on the specifier before validating the callback, which sends the garbage string through the resolver. The resolver's auto-install gate at loadNodeModules only checks esm_ != null; ESModule.Package.parse accepts anything that doesn't start with . or contain \ / %, so the whole function source is treated as a package name. enqueueDependencyToRoot then calls PackageManager.sleepUntil, which re-enters EventLoop.tick() from inside a call that is itself running inside an event-loop tick:

#0 ConcurrentTask.PackedNextPtr.atomicLoadPtr
#1 UnboundedQueue(ConcurrentTask).popBatch
#3 event_loop.tickConcurrentWithCount
#7 AnyEventLoop.tick
#8 PackageManager.sleepUntil
#9 PackageManager.enqueueDependencyToRoot
#10 Resolver.resolveAndAutoInstall
#16 Bun__resolveSyncWithSource
#17 JSMock__jsModuleMock

The same path is reachable from Bun.resolveSync, import(), and require.resolve with any user-provided string.

Fix

Gate the auto-install branch on strings.isNPMPackageName(esm_.?.name). That validator already exists and is used by bun link, bun pm view, and the bundler; it rejects newlines, spaces, braces, and anything else that could never be a registry package. Specifiers failing the check fall straight through to .not_found — the same result the registry fetch would eventually produce — without initializing the package manager or ticking the event loop.

This is a resolver-level fix, so it covers every entry point (not just mock.module). It also avoids spurious network requests for garbage specifiers; on this container a single resolve of a multi-line specifier dropped from ~275ms to ~16ms.

Tests

  • test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts stands up a local registry and verifies zero manifest requests for a set of invalid names with --install=force, plus a positive control that a valid name still hits the registry.
  • test/js/bun/test/mock/mock-module-non-string.test.ts gains a case for mock.module with newline / whitespace / bracket specifiers (with and without a callback).
  • Existing test/cli/run/run-autoinstall.test.ts (11 tests) and test/js/bun/test/mock/mock-module.test.ts all pass.

Related: #28945, #28956, #28500, #28511.
Fingerprint: 2519cad1804eace1

The auto-install path in loadNodeModules would attempt to install any
specifier that parsed as an ESM package, including arbitrary strings
reached via Bun.resolveSync, require.resolve, import, or
mock.module/vi.mock. For a string like "function f2() {\n...}",
enqueueDependencyToRoot would spin up the package manager and
synchronously pump the JS event loop via sleepUntil from inside the
resolver, reentrantly ticking ConcurrentTask.Queue from a call that is
itself running inside EventLoop.tick.

Gate the auto-install branch on isNPMPackageName(esm.name) so specifiers
that can never resolve against a registry fall straight through to
.not_found instead of initializing the package manager and issuing a
network request.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

Updated 5:54 AM PT - Apr 13th, 2026

@robobun, your commit 6dbd60a has 1 failures in Build #45482 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29255

That installs a local version of the PR into your bun-29255 executable, so you can run:

bun-29255 --bun

@github-actions
Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. auto-install crashes when an error is thrown #14432 - Stack trace exactly matches the crash path fixed here: enqueueDependencyToRootsleepUntil → re-entrant EventLoop.tick() SIGSEGV during auto-install
  2. Bun segmentation fault #22407 - Identical crash path (runTasksloadNodeModulesresolveAndAutoInstall) triggered via bunx nx build with unusual module specifiers
  3. Crash with Typescript Compiler API #18960 - Same PackageManager.runTasksloadNodeModules call chain; TypeScript Compiler API resolves non-standard specifiers that could bypass the now-added isNPMPackageName check

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #14432
Fixes #22407
Fixes #18960

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f198e14e-8b64-42b5-9be8-5c4f7fd8b511

📥 Commits

Reviewing files that changed from the base of the PR and between 59ece76 and 6dbd60a.

📒 Files selected for processing (1)
  • test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts

Walkthrough

Resolver's auto-global-cache loading was restricted: Resolver.loadNodeModules now requires parsed ESM package names to be valid NPM package names (strings.isNPMPackageName(...)) before using the package-manager global-cache path. Tests were added for resolver auto-install behavior and mock.module handling of non-package specifiers.

Changes

Cohort / File(s) Summary
Resolver Implementation
src/resolver/resolver.zig
Tightened the guard in Resolver.loadNodeModules to additionally require that the parsed ESM package name is a valid NPM package name (strings.isNPMPackageName(...)) before taking the auto-cached global-cache resolution path.
Resolver Integration Tests
test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts
Added two integration tests that start a local registry returning 404: one verifies that various invalid/non-NPM specifier strings do not trigger registry requests under --install=force, the other verifies that a syntactically valid (but non-existent) npm package does trigger registry requests.
Mock Module Tests
test/js/bun/test/mock/mock-module-non-string.test.ts
Added a test iterating non-NPM-valid module specifiers to assert mock.module(specifier, fn) does not throw, and asserts that calling mock.module(specifier) without a callback throws the expected error; includes explicit Bun.gc(true) at end.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a check to skip auto-install for invalid npm package names in the resolver.
Description check ✅ Passed The PR description comprehensively covers both required sections: it explains the problem (resolver bug causing SIGSEGVs with garbage strings) and verifies the fix through specific tests and benchmarks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

Looked at the three linked issues — not adding Fixes for any of them:

All three share the downstream machinery (enqueueDependencyToRoot → event-loop reentry) but are almost certainly hit with valid package names, so the new check wouldn't short-circuit them. This PR only prevents garbage specifiers from reaching that machinery in the first place.

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. resolver: reject invalid chars in package names to prevent stack-buffer-overflow #28311 - Also validates package names in the resolver to prevent invalid strings from reaching the auto-install path, using character-level rejection instead of isNPMPackageName
  2. mock.module: skip resolve for specifiers with control characters #28945 - Narrower fix in mock.module for the same crash path (skip resolve for control-character specifiers); explicitly referenced as a fixed issue in this PR
  3. mock.module: reject specifiers with whitespace or bracket characters #28956 - Extends mock.module: skip resolve for specifiers with control characters #28945 to whitespace/bracket characters; also explicitly referenced as a fixed issue in this PR
  4. Fix dangling log pointer in PackageManager after resolve #28500 - Fixes the dangling log pointer in PackageManager only reachable via the auto-install path this PR blocks; explicitly referenced by this PR
  5. fix: sync PackageManager.log with resolver.log in resolveMaybeNeedsTrailingSlash #28310 - Same dangling log pointer fix as Fix dangling log pointer in PackageManager after resolve #28500 with a different approach; same crash path eliminated by this PR

🤖 Generated with Claude Code

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

On the duplicates:

#28311 is the closest — it also uses isNPMPackageName* — but it puts the check inside ESModule.Package.parse, so esm_ becomes null for any specifier that fails the allowlist. esm_ is also consumed at resolver.zig:1787 for package.json "exports" resolution during the node_modules walk, so #28311 changes behaviour there too (a locally-installed package whose directory name happens to fail isNPMPackageName would skip exports-map handling and fall through to the legacy loader). This PR gates only the auto-install branch at line 1907; esm_ and the exports path are untouched. The test here also counts registry requests against a local server instead of using a wall-clock threshold, which the #28311 reviewer flagged.

#28945 / #28956 are narrower guards inside JSMock__jsModuleMock that only cover mock.module. This PR fixes the same specifiers for every entry point (Bun.resolveSync, import(), require.resolve, mock.module) one layer down.

#28500 / #28310 fix the stale PackageManager.log pointer inside resolveMaybeNeedsTrailingSlash. That's a separate bug in the same machinery — still worth landing regardless of this PR, since valid package names continue to reach sleepUntil.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted one-line fix with solid test coverage.

Extended reasoning...

Overview

The PR touches three files: one line changed in src/resolver/resolver.zig (the auto-install gate), and two test files — a new integration test for the resolver and an additional case in an existing mock-module test.

Security Risks

The change reduces the attack surface: previously any user-controlled string (e.g. from Bun.resolveSync, import(), require.resolve, or mock.module) could trigger event-loop re-entrancy via PackageManager.sleepUntil, causing a SIGSEGV. The fix closes this path by rejecting strings that are not valid npm package names before the package manager is ever initialized. No new security risks are introduced.

Level of Scrutiny

Low. The code change is a single boolean guard appended to an existing condition. strings.isNPMPackageName is a pre-existing, well-tested utility with clear semantics; the PR description confirms it is already used in bun link, bun pm view, the bundler, and pnpm import. The logic is additive and non-breaking for valid package names.

Other Factors

Tests cover the exact crash-inducing inputs from the Fuzzilli report as well as a positive control (valid name still reaches the registry). The fix is resolver-level, so it covers all entry points rather than patching only mock.module. No CODEOWNERS or security-sensitive subsystems are affected beyond the resolver guard.


// this is the magic!
if (global_cache.canUse(any_node_modules_folder) and r.usePackageManager() and esm_ != null) {
if (global_cache.canUse(any_node_modules_folder) and r.usePackageManager() and esm_ != null and strings.isNPMPackageName(esm_.?.name)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 The new gate at resolver.zig:1907 relies on strings.isNPMPackageName, but that function has a pre-existing bug where empty-scope specifiers like @/pkg incorrectly pass the check. This means @/pkg-style specifiers still reach the auto-install path, potentially triggering the same reentrant EventLoop.tick() SIGSEGV this PR aims to prevent. The fix in isNPMPackageNameIgnoreLength should use slash_index > 1 instead of slash_index > 0 to require at least one character between @ and /.

Extended reasoning...

What the bug is:
isNPMPackageNameIgnoreLength in src/string/immutable.zig uses the condition slash_index > 0 when validating scoped package names, but this condition accepts empty-scope specifiers like @/pkg. A valid scoped npm package requires at least one character between @ and / (e.g., @scope/pkg), so the condition should be slash_index > 1 (or equivalently >= 2).

The specific code path:
In isNPMPackageNameIgnoreLength, for input @/pkg: target[0] == '@' so scoped = true. The loop iterates over target[1..] = /pkg; at i=0, it encounters / and sets slash_index = i + 1 = 1. The final return is !scoped or slash_index > 0 and slash_index + 1 < target.len. Due to Zig's operator precedence (and binds tighter than or), this evaluates as (!true) or ((1 > 0) and (2 < 5)) = false or (true and true) = true. So @/pkg passes the validator despite having an empty scope name.

Why existing code doesn't prevent it:
The bug is structural: slash_index is set to i + 1 where i is 0-based within target[1..]. When the slash is immediately after @ (index 1 in the original string, index 0 in the slice), slash_index becomes 1. The condition slash_index > 0 is satisfied by any scoped name, including those with empty scope. The correct threshold is slash_index > 1 (meaning the slash must be at least at position 2 in the original string, requiring one non-empty scope character).

Impact:
The new guard at resolver.zig:1907 was added specifically to prevent garbage specifiers from reaching enqueueDependencyToRoot → sleepUntil → EventLoop.tick() reentry. Specifiers matching @/anything bypass this guard and still reach the auto-install path. This means: (1) wasteful registry requests for @/anything packages that can never exist, and (2) potential exposure to the same reentrant crash the PR aims to block, via @/pkg-style specifiers.

How to fix it:
In src/string/immutable.zig, inside isNPMPackageNameIgnoreLength, change the final return condition from slash_index > 0 to slash_index > 1. This ensures the scope portion has at least one character.

Step-by-step proof with @/pkg (length 5):

  1. ESModule.Package.parseName("@/pkg") — first char is @, finds first slash at index 1, slash2 at index 3 (end of "pkg"), returns specifier[0..5] = "@/pkg".
  2. ESModule.Package.parse("@/pkg") — returns a non-null Package with name = "@/pkg" (no leading dot, backslash, or percent).
  3. At resolver.zig:1907 — esm_ != null (step 2) and strings.isNPMPackageName("@/pkg") is called.
  4. Inside isNPMPackageNameIgnoreLength("@/pkg"): scoped = true; loop over "/pkg" sets slash_index = 1; return !true or (1>0 and 2<5) = true.
  5. Gate passes → enqueueDependencyToRoot called → PackageManager.sleepUntil → reentrant EventLoop.tick() → potential SIGSEGV.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — isNPMPackageName("@/pkg") does return true because slash_index is 1 when / immediately follows @, and the final return only checks > 0.

That said, it's a pre-existing limitation of the shared validator (used by bun add, bun link, bun pm view, pnpm import, the bundler, and now here), not something this PR introduces — @/pkg already reached enqueueDependencyToRoot before this change. The Fuzzilli crash this PR targets (function f2() {\n...}) is blocked, and the gate is a strict improvement over no validation.

Tightening the final return to slash_index > 1 is correct but touches all seven call sites and deserves its own unit coverage for isNPMPackageName — I'd rather land that as a separate one-liner than widen this crash fix.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

The only CI failure is test/js/bun/webview/webview-chrome.test.ts on 🍎 13 aarch64 — a pre-existing flake also present on #29254, #29252, #29249, #29248 and unrelated to the resolver change. All other test jobs (Linux, Windows, darwin-x64, darwin-14-aarch64, ASAN) pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant