Repository navigation
fix(ext/node): fix module resolution for nested package.json files - #33767
Conversation
fibibot
left a comment
There was a problem hiding this comment.
Thanks for the AI-tool disclosure and the precise issue reference (#33766 → regression from #33170). The diagnosis is right: #33170 added a path-traversal guard using requestPath (the directory of the innermost package.json) as the boundary, which rejects the legitimate pattern of a nested package.json whose main points up into the outer package's tree. The new positive test (main_nested_package_cjs) sits in the existing byonm_main_path_traversal spec dir alongside the existing negative tests, so the regression check and the security check are anchored together — good.
Substance walk
- JS side (
01_require.js:391–410) and Rust side (resolution.rs:2007–2026) implement the same shape: walk parent directories, stop at filesystem root or anode_modulesbasename, updatepackageRootto the highest ancestor that haspackage.json. So fornode_modules/nested-pkg/sub/the loop visitsnested-pkg/(haspackage.json→packageRoot = nested-pkg/), thennode_modules/(basename match → break). End result: boundary isnested-pkg/,../cjs/sub.jsresolves cleanly, traversal still blocked atnode_modules/. The two implementations stay in lockstep, which is right. stat(...) === 0matches the existingtryFileconvention (0 = regular file) and benefits from the existingstatCache, so the per-requireoverhead is bounded.- pnpm-style hoisted layouts still work:
node_modules/.pnpm/[email protected]/node_modules/pkg/sub/walks up topkg/and breaks at the innernode_modules, which is the right boundary. - The negative case still fires: the existing
main_cjsandmain_esmtests in this same spec exercise an outer-package.jsonwithmain: "../../target.cjs"(genuine path-traversal attack), and after the fix the boundary for those is still the outer package directory, so the guard rejects them. That's preserved by both pre-existing assertions in__test__.jsonc:6–14.
Edge cases worth thinking about
- Workspace root
package.jsonoverlap. In a workspace layout/workspace/package.json+/workspace/packages/pkg-a/package.json+/workspace/packages/pkg-a/sub/package.json, the walk reaches/workspace/(since none of the intermediates isnode_modules), andpackageRootbecomes the workspace root — meaning a malicious"main": "../../sibling-pkg/secret.json"frompkg-a/sub/package.jsonwould pass the boundary check. That's probably fine in practice (workspaces are trusted code by definition), but worth confirming with bartlomieju since #33170's intent was specifically guarding the boundary at install scope. - No outer
package.jsonat all. IfrequestPathhas no ancestor withpackage.jsonbefore the filesystem root, the walk runs all the way up and ends withpackageRoot === requestPath(initial value), which preserves the old behavior — good. - Symlinks.
pathDirname/Path::parent()operate lexically, so symlinked package roots aren't followed. Existing behavior, just noting it doesn't get worse.
CI / process
CI is essentially empty so far (1 success, 1 license/cla pending) — no real test signal yet. Worth waiting for the full suite to come back before judging substance against existing module-resolution coverage. Author labels this a "draft implementation" so further iteration is expected.
The regression in node_resolver/resolution.rs traces back to #33170 (path traversal hardening), so cc @bartlomieju on the boundary semantics — any refinement of the package boundary in this file is the kind of call that warrants the original author's eye, especially since the workspace-root edge case noted above moves the boundary in a way the original guard didn't anticipate.
…em walks Instead of walking up directories and stat'ing for package.json files to find the package root, derive it from the path structure by locating the node_modules component and taking the next segment (or two for scoped @scope/pkg packages). This avoids unnecessary filesystem I/O on every tryPackage call and is simpler to reason about.
Verifies that a nested package.json (e.g. nested-pkg/evil-sub/) with a main field that tries to escape the parent package root via path traversal (../../..) is still correctly blocked.
fibibot
left a comment
There was a problem hiding this comment.
Significant rework since my prior pass at e5e2565. The new approach (derive package root from path structure rather than filesystem walks) is conceptually cleaner — find_package_root_from_node_modules walks the path components looking for node_modules, then takes the next 1 (unscoped) or 2 (scoped) components as the package root. No syscalls, just string parsing. The new spec test main_nested_escape_cjs correctly pins that nested-pkg/evil-sub with main: "../../../secret.json" is rejected by the path-traversal check.
Blocking concern: 6 CI shards regressed on tests this refactor's logic touches
test node_compat (1/3, 2/3, 3/3) debug linux-aarch64 and the same three shards on macos-aarch64 all fail on the same two tests:
parallel/test-require-resolve.js— fails withCannot find module '/home/runner/.../node_modules/no_index/lib/index.js'. Please verify that the package.json has a valid "main" entryparallel/test-require-extension-over-directory.js— same flavor
Both are upstream Node tests for the require() resolution path that this PR's refactor directly modifies. They would have been passing on main before this PR.
Walking through the no_index case to figure out where it breaks:
package_json.path = .../node_modules/no_index/package.jsonpackage_path = .../node_modules/no_indexfind_package_root_from_node_modulesreturns.../node_modules/no_index(matchesnode_modulescomponent, takes next =no_index, no@prefix). Sopackage_root == package_path.guess = .../node_modules/no_index/lib/index.js(assuming main is./lib/index.js)guess.starts_with(&package_root)→ true, path-traversal check passesself.sys.is_file(&guess)→ presumably false (or the file is elsewhere)- Falls through to extension probing loop
The error message points at the failure of the is_file check, but the message format Cannot find module '<path>'. Please verify that the package.json has a valid "main" entry is the JS-side error from 01_require.js:tryPackage. So the JS-side packageRoot derivation may differ from the Rust-side one in a way that's biting.
Possible JS-side bug
Looking at the JS 01_require.js changes:
let packageRoot = requestPath;
let current = requestPath;
while (true) {
const parent = pathDirname(current);
if (parent === current) break;
const basename = op_require_path_basename(current);
if (basename === "node_modules") {
// current is the node_modules dir...The loop checks basename(current), then advances current = parent. But the initial value is current = requestPath, and the first iteration checks basename(requestPath) — which for node_modules/no_index is no_index, not node_modules. The loop only advances on current = parent AFTER the body, so the first iteration's current is the deepest path. When current finally becomes node_modules, requestPath.slice(current.length + 1) correctly gives "no_index".
But — requestPath here is whatever was passed to tryPackage. For the no_index test, what is it? If it's the package directory node_modules/no_index/, then current walks [no_index, node_modules] and matches at iteration 2. If it's node_modules/no_index/lib/, then current walks [lib, no_index, node_modules] and matches at iteration 3 — same final result.
Actually I think the issue may be more subtle: for the extensions over directory and require resolve tests, the path may include a fixture path with node_modules embedded that happens to be the test fixture's own node_modules, not an actual npm install. If the fixture path is e.g. tests/.../fixtures/node_modules/no_index/lib, the new code would derive tests/.../fixtures/node_modules/no_index as packageRoot, then check that filename (which the test resolves) starts with it. If the test's resolved filename uses a different path representation (e.g., normalised differently or via symlinks), the startsWith check would fail and the error would be raised.
Worth running these two tests locally with cargo test --test node_compat -- test-require-resolve test-require-extension-over-directory and adding a debug log of packageRoot vs filename to see the actual mismatch. The most likely culprits are:
- Trailing-slash inconsistency —
packageRootcould be".../no_index"whilefilenameis".../no_index/lib/index.js"and the+ "/"concatenation should make them match, but if the OS uses\on Windows andcurrent + sepdoesn't, that's a divergence. - Path separator detection —
const sep = rel.indexOf("/") !== -1 ? "/" : "\\";is brittle: ifrelis just one component (no separator), defaults to"\\"even on Unix. For the no_index case withparts.length === 1,packageRoot = current + sep + parts[0]would use"\\"separator on Unix, producingnode_modules\\no_indexwhich doesn't match anything.
That's the most likely bug — separator default to backslash when the path has no separator after node_modules. Fix:
const sep = current.indexOf("\\") !== -1 ? "\\" : "/";or better, normalize via op_require_path_join or similar.
CI is consistently failing on the aarch64 shards (linux + macos), which use Unix paths exclusively. That matches the suspected bug — separator detection failing when the rel string has no embedded /. ✓
Direction
Fix the JS-side separator detection and the 6 failing shards should clear. Substance of the refactor is otherwise the right shape — happy to come back once those pass.
When rel is a single component (e.g. "no_index" with no separator),
the old check `rel.indexOf("/") !== -1` would be false and default
to "\\", producing broken paths like "node_modules\\no_index" on Unix.
Detect the separator from the node_modules path itself instead.
The pathDirname loop would throw "Path doesn't have a parent" for packages in the global cache (no node_modules in path). Replace with string-based lastIndexOf to find the package root. Also fix the Rust implementation to use rposition (last node_modules component) for correctness with nested node_modules trees.
fibibot
left a comment
There was a problem hiding this comment.
LGTM. The new commit 1121c9e does the exact one-character clippy fix I called out at cbbafc0 (prefix.push(&components[nm_idx + 2]) -> prefix.push(components[nm_idx + 2]) - drops the & that triggers clippy::needless_borrows_for_generic_args). And the coordination question from my prior review is now resolved: #33804 is closed, so this is the canonical version going forward and credit-attribution stays with @mhrstmnn.
Substance recap (validated at cbbafc0, unchanged since)
- JS-side
findPackageRootFromNodeModules(ext/node/polyfills/01_require.js): latelastIndexOf("/node_modules/")vslastIndexOf("\\node_modules\\")to pick the rightmostnode_modulesseparator, scoped-package handling for@scope/pkgpaths, fallback tonullwhen nonode_modulesancestor exists. - Rust-side
find_package_root_from_node_modules(libs/node_resolver/resolution.rs:2781):rposition-based last-node_moduleslookup,&components[..=nm_idx]prefix build, scoped-vs-unscoped branch on@-prefixed first segment. - Spec test fixtures under
tests/specs/npm/nested_package_json/: nested package + traversal-escapeevil-sub/maintest that pins the security boundary (a nested package can'tmaina path outside its own root).
CI
97 success, 1 skipped, 2 fail. Both failures are unrelated infrastructure:
wpt release linux-x86_64: expired WPT test-asset TLS certificate -certificate is not valid after 1717209917 (60677885 seconds ago). Same flake biting many other PRs in the queue today.ci status: rollup of the wpt failure above.
No node_compat, unit, unit_node, specs, or integration test failures - the specific test surface for this resolver change is fully green.
…4737) ## Summary `react-remove-scroll-bar/constants` (reported in #34585) fails on Deno 2.7.14: the package exposes a sibling file via a nested `subpath/package.json` whose `"main": "../dist/sub.js"` traverses out of the `subpath/` directory. The pre-fix CJS path-traversal guard in `tryPackage` (`ext/node/polyfills/01_require.js`) compared the resolved file against the requested subdirectory, rejected the sibling target, and threw the `Please verify that the package.json has a valid "main" entry` error in the user's stack trace. PR #33767 already landed the fix on main: it widens the guard to the enclosing `node_modules/<pkg>` package root via `findPackageRootFromNodeModules`, matching Node.js semantics. The existing `byonm_main_path_traversal/main_nested_package_cjs` test covers a hand-placed flat `node_modules/` layout, but the user hit the bug via `deno install`'s pnpm-style `node_modules/.deno/<pkg>@<v>/node_modules/<dep>/` layout (where `<dep>` is a symlink into the canonical store path). This adds an end-to-end regression test that exercises the auto (`.deno`) layout against a real npm install so the auto code path is explicitly covered. The new test package `@denotest/legacy-subpath-main` mirrors `react-remove-scroll-bar`'s shape: a `subpath/package.json` with `"main": "../dist/sub.js"` and a sibling `dist/sub.js`. Refs #34585 ## Test plan - [x] `cargo test --test specs -- npm::legacy_subpath_main` passes against current main - [x] `cargo test --test specs -- npm::byonm_main_path_traversal` still passes Closes denoland/divybot#428 Co-authored-by: divybot <[email protected]> Co-authored-by: Divy Srivastava <[email protected]>
…enoland#33767) Closes denoland#33766. --------- Co-authored-by: Bartek Iwańczuk <[email protected]>
This PR is related to Issue #33766.
It adds a failing test case demonstrating the regression introduced in #33170, where nested
package.jsonfiles (often used for subpath exports) fail to resolve if theirmainfield points to a parent directory within the same package.I've also included a draft implementation to fix this by attempting to find the actual package root, rather than strictly using the nested
package.json's directory as the boundary.I used Gemini to help me understanding the issue and finding a solution.