From 53cdb3751fb2de559ee11b7e1a4cd14dc89a0947 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:24:36 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20path=20traversal=20in=20gherkin=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++++ src/export/gherkin.ts | 9 +++++++-- tests/gherkin.test.ts | 30 ++++++++++++++++++++++++++++-- 3 files changed, 39 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..600ab92 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-06-13 - Fix path traversal in exportGherkin +**Vulnerability:** The `exportGherkin` function allows writing the exported feature file to any path on the system via the `output` argument, because it does not validate that the resolved path is within the project directory. +**Learning:** `path.join(root, output)` does not prevent path traversal if `output` starts with `../` or is an absolute path. +**Prevention:** Construct absolute paths using `path.resolve()` and verify boundaries using `const rel = path.relative(root, target)`. Always check both `rel.startsWith("..")` and `path.isAbsolute(rel)` to ensure security. diff --git a/src/export/gherkin.ts b/src/export/gherkin.ts index 4710ea9..f0975b8 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, isAbsolute, join, relative, resolve, 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,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", "Output path must be within 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..e54ca81 100644 --- a/tests/gherkin.test.ts +++ b/tests/gherkin.test.ts @@ -1,8 +1,13 @@ import { readFileSync } from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { renderGherkin } from "../src/export/gherkin.js"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { exportGherkin, renderGherkin } 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"; describe("gherkin export", () => { it("renders the golden feature byte-for-byte", () => { @@ -11,3 +16,24 @@ describe("gherkin export", () => { expect(renderGherkin(useCase)).toBe(expected); }); }); + +describe("exportGherkin security", () => { + let root: string; + + beforeEach(() => { + root = join(tmpdir(), `vspec-gherkin-${randomUUID()}`); + mkdirSync(root, { recursive: true }); + initProject({ root }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("prevents path traversal outside the project directory", () => { + const { key } = createUseCase({ title: "Test Traversal", primaryActor: "user", cwd: root }); + expect(() => { + exportGherkin({ key, output: "../../../../tmp/pwned.feature", cwd: root }); + }).toThrow(/Output path must be within the project root/); + }); +});