Skip to content

fix(resolution): keep the existence probe inside the project root (#1631) - #1632

Open
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1631-fileexists-containment
Open

fix(resolution): keep the existence probe inside the project root (#1631)#1632
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1631-fileexists-containment

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Fixes #1631.

fileExists fell back to path.join(this.projectRoot, filePath), which does not clamp, while relative-import resolution hands it paths carrying ../ segments. A crafted relative import in an indexed file therefore made the resolver stat arbitrary absolute paths. Nothing outside the root is read (the content sinks are guarded separately by validatePathWithinRoot) and no edge is produced, but the probe is an existence oracle driven by repository content — and every other layer contains paths, so this one quietly not doing it is surprising on its own.

The knownFiles set is consulted first, so any indexed file resolves regardless of where it lives; only the filesystem fallback needs bounding. A path outside the root can never be an indexed project file, so returning false is the correct answer rather than a new restriction.

Why lexical containment, and not validatePathWithinRoot

The issue suggests validatePathWithinRoot(root, p, { allowSymlinkEscape: true }). That is right about the intent but wrong about the cost here: fileExists runs per import candidate, and validatePathWithinRoot does two realpathSync calls per invocation. Measured over 20,000 calls:

existing file missing file
before (unguarded) 1.54 µs/call 1.38 µs/call
validatePathWithinRoot 105.77 µs/call 153.24 µs/call
lexical only (this PR) 1.86 µs/call 1.91 µs/call

The missing-file column is worse because the realpath pair goes through the ENOENT throw path, and building the exception dominates.

It is also the wrong check semantically. The symlink half exists to stop an in-root symlink whose real target escapes the root (#527) — but indexing deliberately follows exactly those symlinks (#935), which is why allowSymlinkEscape exists in the first place. On this path only the lexical ../ escape should be refused.

So this splits the lexical half out as lexicalPathWithinRoot and has validatePathWithinRoot call it, rather than duplicating the containment rule. Its doc comment says plainly that it is not a substitute for the full check on any path whose contents get served.

Tests

__tests__/resolution-fileexists-containment.test.ts, three cases:

  • a path escaping the root is refused even though the target really exists (the test asserts the target's existence first, so false can only come from the guard) — this is the one that fails before the change
  • files inside the root still resolve, present and absent alike
  • an in-root symlink pointing outside the root still resolves — green before and after, guarding the Path traversal blocked in batch reader { #935 behaviour against being closed off by this change

Full suite: 179 files / 3055 tests passing (3052 on main plus these three). npm run build clean.

Reported and diagnosed by @ErQrYfkrju, who deliberately kept it out of #1630 so it wouldn't land as a single-language guarantee.

…lbymchenry#1631)

`fileExists` fell back to `path.join(projectRoot, filePath)`, which does not
clamp, while relative-import resolution hands it paths carrying `../`
segments — so a crafted import in an indexed file made the resolver stat
arbitrary absolute paths. Nothing outside the root is read (the content sinks
are guarded separately) and no edge is produced, but the probe is an existence
oracle driven by repository content.

Contain the fallback lexically. A path outside the root can never be an
indexed project file, and the `knownFiles` set consulted first already
answered for every path that is, so refusing is the correct result rather
than a new restriction.

Lexical containment only, via a new `lexicalPathWithinRoot` split out of
`validatePathWithinRoot`: this is a per-candidate hot path, and the symlink
half costs two `realpathSync` calls per probe — measured at ~106-153us/call
against ~1.5us before, a ~70-100x regression. It is also the wrong check here,
since indexing deliberately follows in-root symlinks whose targets live
outside the root (colbymchenry#935). With the lexical guard the probe costs ~1.9us/call.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolution: fileExists probes paths outside the project root (uncontained path.join fallback)

1 participant