diff --git a/src/core/walker.ts b/src/core/walker.ts index 8b391d6..2749c53 100644 --- a/src/core/walker.ts +++ b/src/core/walker.ts @@ -1,8 +1,8 @@ // Gitignore-aware recursive directory walker with depth control // Returns filtered file paths respecting project ignore patterns -import { readdir, readFile, stat } from "fs/promises"; -import { join, relative, resolve } from "path"; +import { readdir, readFile, realpath, stat } from "fs/promises"; +import { isAbsolute, join, relative, resolve } from "path"; import ignore, { type Ignore } from "ignore"; export interface WalkOptions { @@ -38,6 +38,11 @@ const ALWAYS_IGNORE = new Set([ ".parcel-cache", ]); +function isWithinRoot(rootDir: string, targetPath: string): boolean { + const relPath = relative(rootDir, targetPath); + return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath)); +} + async function loadIgnoreRules(rootDir: string): Promise { const ig = ignore(); try { @@ -76,16 +81,23 @@ async function walkRecursive( export async function walkDirectory(options: WalkOptions): Promise { const rootDir = resolve(options.rootDir); const startDir = options.targetPath ? resolve(rootDir, options.targetPath) : rootDir; - const ig = await loadIgnoreRules(rootDir); const results: FileEntry[] = []; + let rootRealPath: string; + let startRealPath: string; try { - await stat(startDir); + [rootRealPath, startRealPath] = await Promise.all([realpath(rootDir), realpath(startDir)]); + await stat(startRealPath); } catch { return results; } - await walkRecursive(startDir, rootDir, ig, 0, options.depthLimit ?? 0, results); + if (!isWithinRoot(rootRealPath, startRealPath)) { + throw new Error(`Path traversal denied: "${options.targetPath}" resolves outside root directory`); + } + + const ig = await loadIgnoreRules(rootRealPath); + await walkRecursive(startRealPath, rootRealPath, ig, 0, options.depthLimit ?? 0, results); return results; } diff --git a/test/main/walker.test.mjs b/test/main/walker.test.mjs index 7a36126..e2945f4 100644 --- a/test/main/walker.test.mjs +++ b/test/main/walker.test.mjs @@ -1,7 +1,7 @@ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { walkDirectory, groupByDirectory } from "../../build/core/walker.js"; -import { writeFile, mkdir, rm } from "fs/promises"; +import { writeFile, mkdir, rm, symlink } from "fs/promises"; import { join } from "path"; const FIXTURE_DIR = join(process.cwd(), "test", "_walk_fixtures"); @@ -95,6 +95,51 @@ describe("walker", () => { assert.equal(entries.length, 0); }); + it("rejects targetPath traversal outside root", async () => { + await assert.rejects( + walkDirectory({ + rootDir: FIXTURE_DIR, + targetPath: "..", + }), + /Path traversal denied/, + ); + }); + + it("rejects targetPath traversal to sibling paths with shared prefixes", async () => { + const siblingDir = `${FIXTURE_DIR}-sibling`; + await mkdir(siblingDir, { recursive: true }); + try { + await assert.rejects( + walkDirectory({ + rootDir: FIXTURE_DIR, + targetPath: "../_walk_fixtures-sibling", + }), + /Path traversal denied/, + ); + } finally { + await rm(siblingDir, { recursive: true, force: true }); + } + }); + + it("rejects symlink escapes passed as targetPath", async () => { + const outsideDir = `${FIXTURE_DIR}-outside`; + const linkPath = join(FIXTURE_DIR, "outside-link"); + await mkdir(outsideDir, { recursive: true }); + try { + await symlink(outsideDir, linkPath, "dir"); + await assert.rejects( + walkDirectory({ + rootDir: FIXTURE_DIR, + targetPath: "outside-link", + }), + /Path traversal denied/, + ); + } finally { + await rm(linkPath, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + } + }); + it("includes depth info", async () => { const entries = await walkDirectory({ rootDir: FIXTURE_DIR }); const topLevel = entries.filter((e) => e.depth === 0);