From 1d222f20a71428ff214f67ca98d21c9b110475ee Mon Sep 17 00:00:00 2001 From: Codebuff Contributor Date: Tue, 19 May 2026 14:45:57 +0600 Subject: [PATCH] fix(storage): sanitize snapshot tags to safe filenames (prevent path traversal) This commit fixes issue #5 by introducing a sanitizeTag function that ensures snapshot tags are converted to safe filenames before being used in file paths. This prevents several security and compatibility issues: 1. Path traversal attacks: Tags like '../etc/passwd' or '..\windows\system32' are sanitized to prevent escaping the ~/.apidrift/snapshots/ directory. The function strips leading/trailing dots and replaces '..' sequences to eliminate traversal vectors. 2. Windows filename incompatibility: Characters invalid on Windows filesystems (colon, asterisk, question mark, quotes, angle brackets, pipe) are replaced with underscores, ensuring snapshots work cross-platform. 3. Accidental subdirectory creation: Forward slashes and backslashes in tags are replaced with underscores, preventing unintended directory structures within the snapshot storage. The sanitization algorithm: - Allows only A-Z, a-z, 0-9, dot, underscore, and hyphen characters - Replaces all other characters with underscores - Collapses consecutive underscores into single underscores - Strips leading and trailing underscores and dots - Replaces '..' sequences (path traversal) with underscores - Returns 'untitled' as a safe default for empty/invalid input The function is applied in both saveSnapshot() and loadSnapshot() to ensure consistent behavior. Existing safe tags like 'v1.0.13' and 'prod-users' continue to work unchanged. Comprehensive test coverage with 10 test cases validates path traversal prevention, Windows character handling, empty input, consecutive unsafe characters, and preservation of safe characters. All 16 tests pass. --- src/storage/sanitizeTag.js | 44 ++++++++++++++++++++++ src/storage/snapshotStore.js | 7 +++- tests/core/sanitizeTag.test.js | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/storage/sanitizeTag.js create mode 100644 tests/core/sanitizeTag.test.js 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"); + }); +});