From 2965b8a6eb363f61a3fe4b51e4695746e85e2c04 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:36:17 +0000 Subject: [PATCH] fix: prevent path traversal in exportGherkin The `--output` argument in `vspec export gherkin` was previously joined directly with the root path, making it susceptible to path traversal via `../` escape sequences. This change introduces an absolute path resolution and a boundary check to throw a `VspecError` before generating files if the path traverses outside the repository root. A test case is included to verify this behavior. --- .jules/sentinel.md | 5 +++++ src/export/gherkin.ts | 11 +++++++++-- tests/gherkin.test.ts | 13 +++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..f327654 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,5 @@ + +## 2024-05-20 - Fix path traversal vulnerability in exportGherkin +**Vulnerability:** The `--output` argument in `exportGherkin` was not properly validated against path traversal (e.g. `../../etc/passwd`). +**Learning:** `join()` is vulnerable to path traversal if the user-controlled input contains relative path markers. Because this is a CLI executing locally, arbitrary file paths could allow file overwriting outside the working directory bounds. +**Prevention:** Construct absolute paths using `path.resolve()` and verify path boundaries using `const rel = path.relative(root, target)`. Check `rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)` to strictly prevent traversal outside the project root. diff --git a/src/export/gherkin.ts b/src/export/gherkin.ts index 4710ea9..d0f44a3 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, resolve, relative, isAbsolute, sep } 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 === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new VspecError("INVALID_ARGUMENT", `Path traversal detected: ${args.output} is outside 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..ea2cff1 100644 --- a/tests/gherkin.test.ts +++ b/tests/gherkin.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { renderGherkin } from "../src/export/gherkin.js"; +import { describe, expect, it, vi } from "vitest"; +import { renderGherkin, exportGherkin } from "../src/export/gherkin.js"; import { parseUseCaseMarkdown } from "../src/format/parse.js"; describe("gherkin export", () => { @@ -10,4 +10,13 @@ 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 outside project root in exportGherkin", () => { + const cwd = join(import.meta.dirname, "fixtures/export"); + + // We expect it to throw a VspecError with INVALID_ARGUMENT + expect(() => { + exportGherkin({ key: "VSPEC-010", output: "../../../../../../../../../etc/passwd", cwd }); + }).toThrow(/Path traversal detected/); + }); });