From b54128f4f68afcb613fdf2c1e0f1fddeebefb3b3 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 24 Jun 2026 08:51:30 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU --- .../languages/typescript/nuxt-auto-imports.ts | 14 +++++++- .../typescript/nuxt-auto-imports.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts index 90253a533..ec7800dd1 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts @@ -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 { const directFile = await firstExistingFile([...FILE_EXTENSIONS.map((ext) => base + ext), base]); if (directFile !== null) return directFile; diff --git a/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts index ca31dc76e..af04ebb5d 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts @@ -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', () => {