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 @@
## 2024-06-16 - Prevent Path Traversal in Export Path
**Vulnerability:** The `exportGherkin` command accepted arbitrary paths via the `--output` option and concatenated them with the project root using `join()`, allowing path traversal (e.g., `../../../../tmp/hacked.feature`) to write files outside the intended project directory boundaries.
**Learning:** In CLI applications that write to user-specified paths, using `join()` alone is insufficient for preventing traversal attacks. Path boundaries must be explicitly validated.
**Prevention:** Always use `resolve()` to compute the absolute target path, use `relative(root, target)` to calculate the distance, and check for `..`, `startsWith(".." + sep)`, or `isAbsolute(rel)` to ensure the resolved path stays within the intended directory boundary.
9 changes: 7 additions & 2 deletions src/export/gherkin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { VspecError } from "../errors.js";
import { findUseCaseFile, readConfig, relativePath } from "../files.js";
import { parseUseCaseMarkdown } from "../format/parse.js";
import type { ParsedUseCase } from "../domain/types.js";
Expand Down Expand Up @@ -39,7 +40,11 @@ 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", "Invalid output path: Path traversal detected.");
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, text);
return { key: args.key, text, path: relativePath(outputPath, config.root) };
Expand Down
49 changes: 49 additions & 0 deletions tests/gherkin-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { join } from "node:path";
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { exportGherkin } from "../src/export/gherkin.js";
import { initProject } from "../src/project.js";

describe("gherkin security", () => {
let cwd: string;

beforeEach(() => {
cwd = join(tmpdir(), randomUUID());
mkdirSync(cwd, { recursive: true });
initProject({ key: "TEST", root: cwd });
mkdirSync(join(cwd, "specs/usecases"), { recursive: true });

// Create a dummy use case file
const md = `---
vspec_format: 1
type: usecase
key: TEST-001
title: Test use case
scope: test_project
level: USER_GOAL
primary_actor: system
priority: P1
status: DRAFT
format: BRIEF
---
`;
writeFileSync(join(cwd, "specs/usecases/TEST-001-test.md"), md);
});

afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});

it("prevents path traversal in output path", () => {
expect(() =>
exportGherkin({ key: "TEST-001", output: "../../../../../tmp/hacked.feature", cwd })
).toThrowError("Invalid output path: Path traversal detected.");
});

it("allows valid output paths inside the project", () => {
const result = exportGherkin({ key: "TEST-001", output: "tests/valid.feature", cwd });
expect(result.path).toBe("tests/valid.feature");
});
});