From cd38c8415add7944f83ff27a0f3225222746ef31 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:23:53 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=ED=83=90=EC=83=89=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=88=98=EC=A0=95=20(Fix=20path=20traversal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exportGherkin` 명령어의 `--output` 인자를 통해 프로젝트 루트(`config.root`) 외부의 임의의 디렉토리에 파일을 쓸 수 있었던 경로 탐색(Path Traversal) 취약점을 해결했습니다. - `path.resolve`를 사용하여 절대 경로를 계산하도록 수정. - `path.relative`와 `startsWith("..")` 및 `isAbsolute` 검증 로직을 도입하여 프로젝트 루트(`config.root`)를 벗어나는 출력을 차단. - 취약점 악용 시도시 `INVALID_ARGUMENT` 코드를 가진 `VspecError`를 반환하도록 안전하게 실패(Fail Securely) 처리. - 보안 수정을 검증하기 위한 테스트 케이스 추가. --- .jules/sentinel.md | 4 ++++ src/export/gherkin.ts | 11 +++++++++-- tests/gherkin.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..d46dfc1 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-06-12 - Prevent Path Traversal in Export Output Path +**Vulnerability:** The `exportGherkin` command allowed specifying absolute paths or relative paths with traversal payloads (`../`) via the `--output` argument, writing generated files outside the isolated project root directory (`config.root`). +**Learning:** `path.join` normalizes paths but does not restrict them to a specific boundary, allowing arbitrary writes on the filesystem wherever the agent/user has permissions. +**Prevention:** Construct absolute paths using `path.resolve(root, target)` and enforce boundaries by confirming the target is within bounds utilizing `const rel = path.relative(root, target)` combined with `rel.startsWith("..")` and `path.isAbsolute(rel)`. Use dedicated errors like `INVALID_ARGUMENT` within the CLI to surface machine-parseable constraints rather than leaking stack traces. diff --git a/src/export/gherkin.ts b/src/export/gherkin.ts index 4710ea9..daa167d 100644 --- a/src/export/gherkin.ts +++ b/src/export/gherkin.ts @@ -1,7 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, resolve, isAbsolute } from "node:path"; import { findUseCaseFile, readConfig, relativePath } from "../files.js"; import { parseUseCaseMarkdown } from "../format/parse.js"; +import { VspecError } from "../errors.js"; import type { ParsedUseCase } from "../domain/types.js"; export function renderGherkin(useCase: ParsedUseCase): string { @@ -39,7 +40,13 @@ export function exportGherkin(args: { key: string; output?: string; cwd?: string if (!source) throw new Error("KEY_NOT_FOUND"); const text = renderGherkin(parseUseCaseMarkdown(readFileSync(source, "utf8"))); const output = args.output ?? join("tests", `${args.key}.feature`); - const outputPath = join(config.root, output); + const outputPath = resolve(config.root, output); + + const rel = relative(config.root, outputPath); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new VspecError("INVALID_ARGUMENT", "Output path must be inside the project root."); + } + mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, text); return { key: args.key, text, path: relativePath(outputPath, config.root) }; diff --git a/tests/gherkin.test.ts b/tests/gherkin.test.ts index 1a0c28e..e67dd91 100644 --- a/tests/gherkin.test.ts +++ b/tests/gherkin.test.ts @@ -1,7 +1,12 @@ import { readFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; -import { renderGherkin } from "../src/export/gherkin.js"; +import { exportGherkin, renderGherkin } from "../src/export/gherkin.js"; +import { initProject } from "../src/project.js"; +import { VspecError } from "../src/errors.js"; import { parseUseCaseMarkdown } from "../src/format/parse.js"; describe("gherkin export", () => { @@ -10,4 +15,42 @@ describe("gherkin export", () => { const expected = readFileSync(join(import.meta.dirname, "fixtures/export/VSPEC-010.feature"), "utf8"); expect(renderGherkin(useCase)).toBe(expected); }); + + it("prevents path traversal when exporting via CLI/API", () => { + const root = join(tmpdir(), `vspec-traversal-test-${randomUUID()}`); + mkdirSync(root, { recursive: true }); + try { + initProject({ root, key: "TEST" }); + mkdirSync(join(root, "specs/usecases"), { recursive: true }); + writeFileSync(join(root, "specs/usecases/TEST-001-test.md"), `--- +vspec_format: 1 +type: usecase +key: TEST-001 +title: Test +scope: System +level: SUMMARY +primary_actor: system +status: DRAFT +priority: P1 +format: CASUAL +--- +## Main Success Scenario +1. system does a thing +## Success Guarantee +Something happens. +## Minimal Guarantee +Nothing happens. +`); + + expect(() => { + exportGherkin({ + cwd: root, + key: "TEST-001", + output: "../../../../tmp/vspec-test-traversal-output.txt", + }); + }).toThrowError(new VspecError("INVALID_ARGUMENT", "Output path must be inside the project root.")); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); });