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 }); + } + }); });