Skip to content

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

Description

@ErQrYfkrju

fileExists in src/resolution/index.ts falls back to the filesystem without containing the path:

fileExists: (filePath: string) => {
  if (this.knownFiles) { /* … index hit, O(1) … */ }
  // Fall back to filesystem for files not yet indexed
  const fullPath = path.join(this.projectRoot, filePath);
  try { return fs.existsSync(fullPath); } catch {  }
}

resolveRelativeImport computes path.relative(projectRoot, basePath), which can carry ../ segments, and path.join does not clamp — path.join('/root', '../../etc/passwd') is /etc/passwd. So a relative import in an indexed file probes existence outside the project root.

Reproduction

Nine lines, no OpenSCAD involved — this affects every language with relative imports:

mkdir -p esc/proj/src esc/outside
echo 'export function outsideSecret() { return 42; }' > esc/outside/secret.js
printf "import { outsideSecret } from '../../../outside/secret.js';\nexport function useIt() { return outsideSecret(); }\n" > esc/proj/src/a.js
cd esc/proj && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm init
strace -f -e trace=newfstatat,stat,lstat,access -o /tmp/tr.txt codegraph init -i .
grep -c "outside/secret.js" /tmp/tr.txt

Result: 9 access() calls against paths two levels above the project root.

access(".../esc/outside/secret.js.js",   F_OK) = -1 ENOENT
access(".../esc/outside/secret.js.jsx",  F_OK) = -1 ENOENT
access(".../esc/outside/secret.js.mjs",  F_OK) = -1 ENOENT
…

Only access — no openat for that path, so nothing outside is read.

Severity, stated plainly

Low, and I would rather understate it than dress it up:

What remains is an existence oracle driven by repository content: a .scad/.js/.py file in an indexed repo can make the indexer probe whether an arbitrary absolute path exists, and timing or subsequent behaviour could in principle expose that. It is also just surprising — every other layer contains paths, and this one quietly does not.

Suggested fix

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

const fullPath = path.join(this.projectRoot, filePath);
if (!validatePathWithinRoot(this.projectRoot, fullPath, { allowSymlinkEscape: true })) {
  return false;
}

allowSymlinkEscape matches the indexing tier established in #935, so an in-root symlink to a vendored library outside the repo keeps working; only the lexical ../ escape is refused.

I have deliberately not included this in #1630 (OpenSCAD support). Fixing it there would give one language a guarantee the other forty lack, and the change belongs to whoever owns this code path rather than to a language PR.

Found while validating OpenSCAD support; confirmed on 6a056ec (v1.6.0).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions