Skip to content

install: compare stored scope name and folder path, not just their hash - #32745

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/2b301445/install-scope-folder-hash-collisions
Jun 26, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/2b301445/install-scope-folder-hash-collisions

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes two install-path sites from #32741 where a fixed-seed non-cryptographic hash is used as an identity key with no comparison of the bytes it was computed from, so a constructed hash collision confuses two distinct inputs. Both follow the store-the-bytes-and-compare invariant established by #31218 for trustedDependencies.

The headline of the report (trustedDependencies lifecycle-script RCE) is already fixed on main by #31218 and is not touched here.

1. Scoped-registry token leak

The registry map is keyed by Scope::hash(scope_name) (Wyhash11) and scope_for_package_name returned whatever scope sat in that slot without checking the name. Two scope names that collide overwrite each other, so a request for one scope was routed to the other scope's registry with the other scope's token.

// .npmrc: scopeA and scopeB collide under Scope::hash (== 0xd2c80616f46b9bf2)
@cuxk...aaaaaaaa...p2s:registry=http://registryA/   // token scope-A-SECRET
@cuxk...bbbbbbbbb...p2s:registry=http://registryB/  // token scope-B-SECRET
// package.json depends only on @cuxk...aaaaaaaa...p2s/probe

Before this change, installing @scopeA/probe sent the request to registry B with Authorization: Bearer scope-B-SECRET-token.

Fix: compare the stored scope.name against the requested scope, and fall back to the default registry on a mismatch.

2. Folder-resolution package confusion

FolderResolution::get_or_put keyed its dedupe map on hash(absolute_package_json_path) (seed-0 Wyhash) and stored only a package id. Two local file: dependencies whose absolute package.json paths collide shared one entry, so the second dependency resolved to the first package's contents:

node_modules/alphadep -> pkg-alpha 1.0.0
node_modules/betadep  -> pkg-alpha 1.0.0   // expected pkg-beta 2.0.0

Fix: store the absolute path alongside the resolution and compare it on lookup; on a hash collision, resolve fresh without evicting the existing entry.

Scope

The same "hash as identity, no byte comparison" pattern exists in a few other install maps from the report (remote tarball URL dedup via Task::Id, PackageNameHash). Those are lower severity (they need the attacker to control both colliding remote URLs, or they fail loudly with a duplicate-dependency error) and touch a shared identity type, so they are left for a focused follow-up rather than widening this change.

Verification

Both tests fail on the unfixed build and pass with the fix.

  • test/cli/install/npmrc.test.ts stands up two in-process registries, configures the colliding scopes, and asserts the colliding scope's registry never receives the request or its token.
  • test/cli/install/bun-install.test.ts constructs the path collision at runtime (self-verified against Bun.hash.wyhash, which is the exact function keying the map) and asserts each file: dependency keeps its own identity. Gated to POSIX.
$ bun bd test test/cli/install/npmrc.test.ts -t "hash-colliding"
(pass) scoped registry routing > does not route to a hash-colliding scope's registry or token

$ bun bd test test/cli/install/bun-install.test.ts -t "colliding abs-path"
(pass) file: deps with colliding abs-path hashes resolve to distinct packages

The scoped-registry map and the folder-resolution dedupe map are keyed by a
fixed-seed non-cryptographic hash (Wyhash11 over scope names, seed-0 Wyhash
over absolute package.json paths) without comparing the bytes the hash was
computed from. A constructed hash collision then confuses two distinct inputs:

- scope_for_package_name resolved @scopeA/pkg through any scope whose name
  hashed to the same value, so a request could be sent to a different scope's
  registry carrying that scope's token. The lookup now compares the stored
  scope name and falls back to the default registry on a mismatch.

- folder resolution keyed only on the path hash, so two local file:
  dependencies whose absolute package.json paths collided shared one
  resolution and the second resolved to the first package's contents. The map
  value now carries the absolute path; the lookup compares it and resolves
  fresh on a collision instead of reusing the entry.

This mirrors #31218, which applied the same store-the-bytes-and-compare
invariant to trustedDependencies.
@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 AM PT - Jun 26th, 2026

❌ @autofix-ci[bot], your commit 12ad7cb has 3 failures in Build #64889 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32745

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

bun-32745 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@autofix-ci[bot], we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 30 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f532578-1cf3-4210-9cbd-5ce02fe5893d

📥 Commits

Reviewing files that changed from the base of the PR and between 8268889 and 12ad7cb.

📒 Files selected for processing (6)
  • src/install/PackageManager.rs
  • src/install/PackageManager/PackageManagerOptions.rs
  • src/install/resolvers/folder_resolver.rs
  • test/cli/install/bun-install.test.ts
  • test/cli/install/npmrc.test.ts
  • test/cli/install/wyhash-std-collision.ts

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

@claude claude Bot left a comment

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.

I didn't find any bugs and the fix follows the #31218 pattern cleanly, but this is a security fix touching auth-token routing and package-identity resolution in the install core, so it warrants a human sign-off (also note the build-rust CI failures on f66a067).

Extended reasoning...

Overview

This PR hardens two install-path lookups against constructed Wyhash collisions: Options::scope_for_package_name now compares the stored scope.name bytes (not just the hash) before returning a scoped registry, and folder_resolver::get_or_put now stores the normalized absolute package.json path alongside each FolderResolution and compares it on lookup. The FolderResolutionMap value type changes from FolderResolution to a new Entry { abs_path, resolution }, with the one direct .put in PackageManager::init updated accordingly. Tests add a runtime Wyhash-collision constructor and two regression tests (scoped-registry token routing, file: dep identity).

Security risks

The change is itself a security fix for (1) a scoped-registry auth-token misrouting — a colliding scope name could cause one scope's request to be sent to another scope's registry carrying that registry's bearer token — and (2) local package confusion between two file: deps whose absolute paths collide. The fix is strictly defensive (extra byte-equality check, fall back to default registry / resolve-fresh on mismatch) and I don't see it introducing new attack surface. Behavior is unchanged in the no-collision case.

Level of scrutiny

High. This is production-critical install-path code that decides which registry receives credentials and which on-disk package is materialized for a dependency. Even though the diff is small and mechanical, the consequences of a subtle mistake here (e.g., scope-name normalization mismatch causing legitimate scoped registries to silently fall back to the default) are user-facing and security-relevant. A human reviewer who knows the Scope::get_name / Scope::name normalization invariants should confirm the byte comparison can't false-negative on a legitimately configured scope.

Other factors

  • robobun reports build-rust failures across all platforms on f66a067; an autofix commit (12ad7cb) landed afterward but the CI status comment hasn't been updated to reflect it, so build health is currently unconfirmed.
  • The PR explicitly leaves related hash-as-identity sites (Task::Id, PackageNameHash) for follow-up — reasonable scoping, but worth a maintainer ack.
  • No CODEOWNERS cover src/install/, and there are no prior human reviews on the thread.
  • Good test coverage: both new tests are constructed to fail pre-fix and self-verify their collision against Bun.hash.wyhash.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Addressing the two points:

The scope-name comparison can't false-negative on a legitimately configured scope. The change only adds a byte-equality check after the existing hash lookup, so it can only alter behavior when the hash matches but the names differ (the collision case). For any scope that resolved correctly before, the looked-up entry's name already equals the queried scope, so the check passes.

Concretely, the stored scope.name and the lookup's get_name are the same normalized form (leading @ stripped):

  • .npmrc: the scope key is stored as key[1..key.len() - ":registry".len()], so @foo:registry becomes foo (src/ini/lib.rs:1247).
  • bunfig.toml [install.scopes]: keys strip a leading @ (src/bunfig/bunfig.rs:1335).
  • Scope::get_name("@foo/bar") returns foo (src/install/npm.rs:314).

So @foo/bar resolves to get_name -> foo, which matches the stored foo. A mismatch happens only on a hash collision with a different name, where the lookup now falls back to the default registry instead of sending the request and token to the unrelated scope. The new regression test exercises the collision, and the existing scoped-registry install tests continue to pass.

Build health is confirmed. The build-rust results on f66a067b were cancellations (that build's state is canceled), triggered when the autofix.ci commit (12ad7cbc, a one-line import dedup) superseded it. On the current HEAD every lane that showed as failing there now passes: darwin x64/aarch64, linux x64/aarch64, x64-baseline, x64-asan (build + test), and aarch64-musl/x64-musl build-rust.

Agreed this warrants a human sign-off given it touches credential routing and package identity.

@Jarred-Sumner
Jarred-Sumner merged commit 76b4a14 into main Jun 26, 2026
75 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/2b301445/install-scope-folder-hash-collisions branch June 26, 2026 07:53
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.

2 participants