Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 additions & 2 deletions src/export/gherkin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) };
Expand Down
45 changes: 44 additions & 1 deletion tests/gherkin.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 });
}
});
});