diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..1c8c9bd --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-06-17 - Fix Path Traversal in exportGherkin +**Vulnerability:** `exportGherkin` accepts an unvalidated `output` path relative to the project root, allowing arbitrary file writes outside the project directory using `../../` or absolute paths. +**Learning:** The `output` option in `vspec export gherkin` lacked validation to ensure the resolved output path stays within the project root directory. +**Prevention:** Always validate constructed file paths when they incorporate user input using `path.resolve` and checking if the resulting relative path begins with `..` or is absolute. diff --git a/src/export/gherkin.ts b/src/export/gherkin.ts index 4710ea9..051c160 100644 --- a/src/export/gherkin.ts +++ b/src/export/gherkin.ts @@ -1,8 +1,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve, relative, sep, isAbsolute } from "node:path"; import { findUseCaseFile, readConfig, relativePath } from "../files.js"; import { parseUseCaseMarkdown } from "../format/parse.js"; import type { ParsedUseCase } from "../domain/types.js"; +import { VspecError } from "../errors.js"; export function renderGherkin(useCase: ParsedUseCase): string { const lines: 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 === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new VspecError("INVALID_ARGUMENT", "Output path must be within the project root directory."); + } + 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..371ac5c 100644 --- a/tests/gherkin.test.ts +++ b/tests/gherkin.test.ts @@ -1,8 +1,13 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { renderGherkin } from "../src/export/gherkin.js"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { renderGherkin, exportGherkin } from "../src/export/gherkin.js"; import { parseUseCaseMarkdown } from "../src/format/parse.js"; +import { initProject } from "../src/project.js"; +import { createUseCase } from "../src/usecase-commands.js"; +import { createActor } from "../src/entity-commands.js"; describe("gherkin export", () => { it("renders the golden feature byte-for-byte", () => { @@ -11,3 +16,28 @@ describe("gherkin export", () => { expect(renderGherkin(useCase)).toBe(expected); }); }); + +describe("exportGherkin command", () => { + let cwd: string; + + beforeEach(() => { + cwd = join(tmpdir(), randomUUID()); + initProject({ root: cwd, key: "TEST" }); + createActor({ name: "user", cwd }); + createUseCase({ title: "Test Path Traversal", primaryActor: "user", cwd }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it("prevents path traversal outside project root", () => { + expect(() => { + exportGherkin({ key: "TEST-001", output: "../../../../../tmp/escaped.feature", cwd }); + }).toThrowError(/Output path must be within the project root directory/); + + expect(() => { + exportGherkin({ key: "TEST-001", output: "/tmp/absolute.feature", cwd }); + }).toThrowError(/Output path must be within the project root directory/); + }); +});