diff --git a/src/storage/sanitizeTag.js b/src/storage/sanitizeTag.js new file mode 100644 index 0000000..ac5a10e --- /dev/null +++ b/src/storage/sanitizeTag.js @@ -0,0 +1,44 @@ +/** + * Sanitizes a snapshot tag into a safe filename. + * + * Only allows A-Z, a-z, 0-9, '.', '_', and '-'. + * All other characters are replaced with '_'. + * + * This prevents: + * - Path traversal attacks via '../' or '..\' + * - Invalid filename characters on Windows (e.g. ':', '*', '?', '"', '<', '>', '|') + * - Accidental subdirectory creation via '/' or '\' + * + * @param {string} tag - The raw snapshot tag from user input. + * @returns {string} A sanitized tag safe for use as a filename. + */ +export function sanitizeTag(tag) { + if (typeof tag !== "string" || tag.length === 0) { + return "untitled"; + } + + // Replace any character that is NOT alphanumeric, dot, underscore, or hyphen + let sanitized = tag.replace(/[^A-Za-z0-9._-]/g, "_"); + + // Collapse consecutive underscores (e.g. "foo___bar" -> "foo_bar") + sanitized = sanitized.replace(/_+/g, "_"); + + // Strip leading/trailing underscores and dots for safety + sanitized = sanitized.replace(/^[_\.]+|[_\.]+$/g, ""); + + // Replace any remaining '..' sequences (path traversal) with '_' + sanitized = sanitized.replace(/\.\./g, "_"); + + // Collapse any new consecutive underscores from the '..' replacement + sanitized = sanitized.replace(/_+/g, "_"); + + // Strip again after '..' replacement + sanitized = sanitized.replace(/^[_\.]+|[_\.]+$/g, ""); + + // Final safety: if sanitization produced an empty string, return a default + if (sanitized.length === 0) { + return "untitled"; + } + + return sanitized; +} diff --git a/src/storage/snapshotStore.js b/src/storage/snapshotStore.js index 0af020d..0b624a6 100644 --- a/src/storage/snapshotStore.js +++ b/src/storage/snapshotStore.js @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import os from "os"; +import { sanitizeTag } from "./sanitizeTag.js"; // Stores globally in ~/.apidrift/snapshots/ const SNAP_DIR = path.join(os.homedir(), ".apidrift", "snapshots"); @@ -8,13 +9,15 @@ const SNAP_DIR = path.join(os.homedir(), ".apidrift", "snapshots"); if (!fs.existsSync(SNAP_DIR)) fs.mkdirSync(SNAP_DIR, { recursive: true }); export function saveSnapshot(tag, data) { - const file = path.join(SNAP_DIR, `${tag}.json`); + const safeTag = sanitizeTag(tag); + const file = path.join(SNAP_DIR, `${safeTag}.json`); fs.writeFileSync(file, JSON.stringify(data, null, 2)); return file; } export function loadSnapshot(tag) { - const file = path.join(SNAP_DIR, `${tag}.json`); + const safeTag = sanitizeTag(tag); + const file = path.join(SNAP_DIR, `${safeTag}.json`); if (!fs.existsSync(file)) { console.error(`Snapshot "${tag}" not found. Run: apidrift list`); process.exit(1); diff --git a/tests/core/sanitizeTag.test.js b/tests/core/sanitizeTag.test.js new file mode 100644 index 0000000..e6f2e99 --- /dev/null +++ b/tests/core/sanitizeTag.test.js @@ -0,0 +1,68 @@ +import { sanitizeTag } from "../../src/storage/sanitizeTag.js"; + +describe("sanitizeTag", () => { + it("passes through safe tags unchanged", () => { + expect(sanitizeTag("v1.0")).toBe("v1.0"); + expect(sanitizeTag("v1.0.13")).toBe("v1.0.13"); + expect(sanitizeTag("prod-users")).toBe("prod-users"); + expect(sanitizeTag("my_snapshot")).toBe("my_snapshot"); + expect(sanitizeTag("abc123")).toBe("abc123"); + }); + + it("replaces colons with underscores", () => { + expect(sanitizeTag("v1:0:0")).toBe("v1_0_0"); + expect(sanitizeTag("2026-05-19T12:00:00")).toBe("2026-05-19T12_00_00"); + }); + + it("prevents path traversal via ../", () => { + expect(sanitizeTag("../etc/passwd")).toBe("etc_passwd"); + expect(sanitizeTag("..\\windows\\system32")).toBe("windows_system32"); + expect(sanitizeTag("foo/../../bar")).toBe("foo_bar"); + }); + + it("blocks path separators", () => { + expect(sanitizeTag("foo/bar")).toBe("foo_bar"); + expect(sanitizeTag("foo\\bar")).toBe("foo_bar"); + }); + + it("handles Windows-invalid characters", () => { + expect(sanitizeTag("con:nul")).toBe("con_nul"); + expect(sanitizeTag("file?.json")).toBe("file_.json"); + expect(sanitizeTag("star*test")).toBe("star_test"); + expect(sanitizeTag('quote"test')).toBe("quote_test"); + expect(sanitizeTag("lessthan")).toBe("greater_than"); + expect(sanitizeTag("pipe|test")).toBe("pipe_test"); + }); + + it("handles empty and invalid input", () => { + expect(sanitizeTag("")).toBe("untitled"); + expect(sanitizeTag(null)).toBe("untitled"); + expect(sanitizeTag(undefined)).toBe("untitled"); + expect(sanitizeTag(123)).toBe("untitled"); + }); + + it("handles tags that are entirely unsafe characters", () => { + expect(sanitizeTag("../../")).toBe("untitled"); + expect(sanitizeTag("...")).toBe("untitled"); + expect(sanitizeTag("///")).toBe("untitled"); + expect(sanitizeTag(" ")).toBe("untitled"); + }); + + it("collapses consecutive unsafe replacements", () => { + expect(sanitizeTag("foo bar")).toBe("foo_bar"); + expect(sanitizeTag("a//b//c")).toBe("a_b_c"); + expect(sanitizeTag("x:y:z")).toBe("x_y_z"); + }); + + it("strips leading and trailing underscores from sanitization", () => { + expect(sanitizeTag("/foo")).toBe("foo"); + expect(sanitizeTag("foo/")).toBe("foo"); + expect(sanitizeTag(":v1.0:")).toBe("v1.0"); + }); + + it("preserves dots, underscores, and hyphens in the middle", () => { + expect(sanitizeTag("my-app_v2.1.0-beta")).toBe("my-app_v2.1.0-beta"); + expect(sanitizeTag("a.b_c-d")).toBe("a.b_c-d"); + }); +});