fix(ingestion): contain .nuxt/imports.d.ts source resolution to the repo

A crafted `.nuxt/imports.d.ts` source such as `from '../../../../etc/passwd'`
passes the project-local relative-path check but resolves outside the analyzed
repo, causing fs.stat probes against arbitrary host paths. Skip any source that
resolves outside repoRoot before touching the filesystem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
This commit is contained in:
Gergo Magyar 2026-06-24 08:51:30 +00:00
parent fe24618814
commit b54128f4f6
2 changed files with 46 additions and 1 deletions

View file

@ -187,7 +187,13 @@ async function collectImportsDts(
if (!isProjectLocalPath(source)) continue;
const resolvedFile = await resolveExtension(path.resolve(nuxtDir, source));
// Containment guard: a crafted source (`from '../../../../etc/passwd'`)
// passes the relative-path check but escapes the repo. Skip anything that
// resolves outside repoRoot before touching the filesystem.
const resolvedBase = path.resolve(nuxtDir, source);
if (!isWithinRepo(repoRoot, resolvedBase)) continue;
const resolvedFile = await resolveExtension(resolvedBase);
if (resolvedFile === null) continue;
const sourceFile = toRepoPosix(repoRoot, resolvedFile);
@ -252,6 +258,12 @@ function isProjectLocalPath(source: string): boolean {
return true;
}
/** True when `absPath` is `repoRoot` itself or lives beneath it. */
function isWithinRepo(repoRoot: string, absPath: string): boolean {
const root = path.resolve(repoRoot);
return absPath === root || absPath.startsWith(root + path.sep);
}
async function resolveExtension(base: string): Promise<string | null> {
const directFile = await firstExistingFile([...FILE_EXTENSIONS.map((ext) => base + ext), base]);
if (directFile !== null) return directFile;

View file

@ -150,6 +150,39 @@ describe('loadNuxtAutoImports', () => {
'second',
]);
});
it('ignores imports.d.ts sources that resolve outside the repo root', async () => {
const root = makeRepo();
// A real file in its own temp dir OUTSIDE the repo, reachable from .nuxt only
// by directory traversal. Its own mkdtemp dir is tracked for cleanup.
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nuxt-escape-'));
tempRoots.push(outsideDir);
const outside = path.join(outsideDir, 'escape.ts');
fs.writeFileSync(outside, 'export function escape() {}', 'utf8');
// Relative traversal from root/.nuxt to the out-of-repo file (extensionless).
const traversal = path
.relative(path.join(root, '.nuxt'), outside)
.replace(/\\/g, '/')
.replace(/\.ts$/, '');
writeFile(
root,
'.nuxt/imports.d.ts',
[
`export { escape } from '${traversal}'`,
"export { useGood } from '../composables/good'",
].join('\n'),
);
writeFile(root, 'composables/good.ts', 'export function useGood() {}');
const config = await loadNuxtAutoImports(root);
// The traversal source is skipped (no entry, no throw); the in-repo one resolves.
expect(config!.clientByLocalName.has('escape')).toBe(false);
expect(config!.clientByLocalName.get('useGood')).toMatchObject({
sourceFile: 'composables/good.ts',
});
});
});
describe('isNitroServerRuntimeFile', () => {