From e45f7287ce54efb7074aee9c6c9ed0be9f65a5f9 Mon Sep 17 00:00:00 2001 From: polidog Date: Sat, 3 Jan 2026 16:12:47 +0900 Subject: [PATCH 1/2] fix(indexer): improve path traversal detection accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change `..` detection from substring match to path segment match. Previously `includes("..")` would incorrectly flag legitimate paths containing `..` as part of filename (e.g., `file..txt`). Now properly splits by path separators and checks for `..` as a discrete segment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/indexer/cli.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/indexer/cli.ts b/src/indexer/cli.ts index 02bf352..9843faa 100644 --- a/src/indexer/cli.ts +++ b/src/indexer/cli.ts @@ -1090,8 +1090,8 @@ async function collectPlainDocsPaths(repoRoot: string): Promise { } } - await walkRelative("docs").catch(() => {}); - await walkRelative("docmeta").catch(() => {}); + await walkRelative("docs").catch(() => { }); + await walkRelative("docmeta").catch(() => { }); return results; } @@ -1512,7 +1512,7 @@ function normalizePathForDenylist(filePath: string): string { normalized = normalized.slice(1); } // パストラバーサル検出(セキュリティ) - if (normalized.includes("..")) { + if (normalized.split(/[/\\]/).includes("..")) { throw new Error(`Path traversal detected: ${filePath}`); } return normalized; @@ -1820,8 +1820,8 @@ export async function runIndexer(options: IndexerOptions): Promise { // ログはフィルタ後の件数を使用して正確な情報を提供 console.info( `No actual changes detected in ${filteredChangedPaths.length} file(s)` + - (filteredCount > 0 ? ` (${filteredCount} filtered by denylist)` : "") + - `. Skipping reindex.` + (filteredCount > 0 ? ` (${filteredCount} filtered by denylist)` : "") + + `. Skipping reindex.` ); // Fix #3 & #4: If files were deleted or purged, still need to dirty FTS and rebuild From baf506ae2652c92a3a989b89b92f340342850033 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Mon, 5 Jan 2026 08:39:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(indexer):=20normalizePathForDenylist?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 関数をエクスポートして単体テストを可能に - 正規化機能のテスト(6件) - パストラバーサル検出のセキュリティテスト(6件) - 誤検知回避のテスト(6件)- PR #214の修正を検証 - エッジケースのテスト(6件) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/indexer/cli.ts | 10 +- .../normalize-path-for-denylist.spec.ts | 112 ++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/indexer/normalize-path-for-denylist.spec.ts diff --git a/src/indexer/cli.ts b/src/indexer/cli.ts index 9843faa..2ab78b6 100644 --- a/src/indexer/cli.ts +++ b/src/indexer/cli.ts @@ -1090,8 +1090,8 @@ async function collectPlainDocsPaths(repoRoot: string): Promise { } } - await walkRelative("docs").catch(() => { }); - await walkRelative("docmeta").catch(() => { }); + await walkRelative("docs").catch(() => {}); + await walkRelative("docmeta").catch(() => {}); return results; } @@ -1500,7 +1500,7 @@ const DELETE_CHUNK_SIZE = 500; * @returns 正規化されたPOSIX相対パス * @throws パストラバーサルが検出された場合 */ -function normalizePathForDenylist(filePath: string): string { +export function normalizePathForDenylist(filePath: string): string { // バックスラッシュをスラッシュに変換(Windows対応) let normalized = filePath.replace(/\\/g, "/"); // 先頭の ./ を除去 @@ -1820,8 +1820,8 @@ export async function runIndexer(options: IndexerOptions): Promise { // ログはフィルタ後の件数を使用して正確な情報を提供 console.info( `No actual changes detected in ${filteredChangedPaths.length} file(s)` + - (filteredCount > 0 ? ` (${filteredCount} filtered by denylist)` : "") + - `. Skipping reindex.` + (filteredCount > 0 ? ` (${filteredCount} filtered by denylist)` : "") + + `. Skipping reindex.` ); // Fix #3 & #4: If files were deleted or purged, still need to dirty FTS and rebuild diff --git a/tests/indexer/normalize-path-for-denylist.spec.ts b/tests/indexer/normalize-path-for-denylist.spec.ts new file mode 100644 index 0000000..2005631 --- /dev/null +++ b/tests/indexer/normalize-path-for-denylist.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { normalizePathForDenylist } from "../../src/indexer/cli.js"; + +describe("normalizePathForDenylist", () => { + describe("正規化機能", () => { + it("通常のパスはそのまま返す", () => { + expect(normalizePathForDenylist("foo/bar.ts")).toBe("foo/bar.ts"); + }); + + it("先頭の ./ を除去する", () => { + expect(normalizePathForDenylist("./foo/bar.ts")).toBe("foo/bar.ts"); + }); + + it("先頭の / を除去する(絶対パス→相対パス)", () => { + expect(normalizePathForDenylist("/foo/bar.ts")).toBe("foo/bar.ts"); + }); + + it("先頭の ./ と / の両方を処理する", () => { + // ./ が先に除去され、その後 / は先頭にないのでそのまま + expect(normalizePathForDenylist("./foo/bar.ts")).toBe("foo/bar.ts"); + }); + + it("バックスラッシュをスラッシュに変換する(Windows対応)", () => { + expect(normalizePathForDenylist("foo\\bar.ts")).toBe("foo/bar.ts"); + }); + + it("Windows形式の .\\path を正規化する", () => { + expect(normalizePathForDenylist(".\\foo\\bar.ts")).toBe("foo/bar.ts"); + }); + }); + + describe("パストラバーサル検出(セキュリティ)", () => { + it("先頭の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("../etc/passwd")).toThrow("Path traversal detected"); + }); + + it("中間の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("foo/../bar.ts")).toThrow("Path traversal detected"); + }); + + it("末尾の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("foo/bar/..")).toThrow("Path traversal detected"); + }); + + it("複数の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("../../secret")).toThrow("Path traversal detected"); + }); + + it("Windows形式の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("foo\\..\\bar")).toThrow("Path traversal detected"); + }); + + it("単独の .. を検出してエラーにする", () => { + expect(() => normalizePathForDenylist("..")).toThrow("Path traversal detected"); + }); + }); + + describe("誤検知回避(PR #214 修正対象)", () => { + it("Next.js catch-all ルート [...all] を許可する", () => { + expect(normalizePathForDenylist("api/auth/[...all]/route.ts")).toBe( + "api/auth/[...all]/route.ts" + ); + }); + + it("ファイル名内の .. を許可する", () => { + expect(normalizePathForDenylist("foo/bar..baz.ts")).toBe("foo/bar..baz.ts"); + }); + + it("ディレクトリ名内の .. を許可する(部分マッチ)", () => { + expect(normalizePathForDenylist("foo/[..bar]/baz.ts")).toBe("foo/[..bar]/baz.ts"); + }); + + it("三点 ... を許可する", () => { + expect(normalizePathForDenylist("foo/.../bar.ts")).toBe("foo/.../bar.ts"); + }); + + it("末尾三点のファイル名を許可する", () => { + expect(normalizePathForDenylist("foo/bar.../baz.ts")).toBe("foo/bar.../baz.ts"); + }); + + it("Next.js optional catch-all [[...slug]] を許可する", () => { + expect(normalizePathForDenylist("[[...slug]]/page.ts")).toBe("[[...slug]]/page.ts"); + }); + }); + + describe("エッジケース", () => { + it("単一のドット . を許可する", () => { + expect(normalizePathForDenylist(".")).toBe("."); + }); + + it("三点のみ ... を許可する", () => { + expect(normalizePathForDenylist("...")).toBe("..."); + }); + + it("空文字を許可する", () => { + expect(normalizePathForDenylist("")).toBe(""); + }); + + it("/ のみの場合は空文字を返す", () => { + expect(normalizePathForDenylist("/")).toBe(""); + }); + + it("./ のみの場合は空文字を返す", () => { + expect(normalizePathForDenylist("./")).toBe(""); + }); + + it("深いネストのパスを正規化する", () => { + expect(normalizePathForDenylist("a/b/c/d/e/f/g.ts")).toBe("a/b/c/d/e/f/g.ts"); + }); + }); +});