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
44 changes: 44 additions & 0 deletions src/storage/sanitizeTag.js
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 5 additions & 2 deletions src/storage/snapshotStore.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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");

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);
Expand Down
68 changes: 68 additions & 0 deletions tests/core/sanitizeTag.test.js
Original file line number Diff line number Diff line change
@@ -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("less<than")).toBe("less_than");
expect(sanitizeTag("greater>than")).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");
});
});