Root cause
Path & link handling introduced
feat: implement snapshot architecture (client, environment, manager, serializers, matchers)
How AI contributed: Direct introductionPublished Jul 2, 2026 in
asymmetric-effort/nogginlessdomAll 2 findings in this repository
Summary The matchFileSnapshot function in src/assertions/snapshots.ts accepted a filePath parameter with zero validation. When snapshot update mode was active (UPDATE_SNAPSHOTS=1 or setUpdateMode('all')), an attacker who controls test input could write arbitrary content to any filesystem path the process has write access to, including creating intermediate directories. Affected Code File: src/assertions/snapshots.ts, lines 732-769 The filePath flows from expect(value).toMatchFileSnapshot(filePath) at index.ts:1033-1035 with no sanitization, no check that the path is within an expected directory, and no symlink resolution. Proof of Concept Impact In CI/CD environments where test files may come from untrusted pull requests, this allows writing to any writable filesystem location with directory creation. An attacker could overwrite configuration files, inject code into build artifacts, or modify CI pipeline definitions. Fix Fixed in commit https://github.com/asymmetric-effort/NogginLessDom/commit/785e6ac6e124d1a89b3ccf40bbd75fc8e4cb215d on main. The matchFileSnapshot function now validates that the resolved file path is within the project root directory (defaults to process.cwd(), configurable via optional projectRoot parameter). Paths that resolve outside the project directory are rejected with a descriptive error.
matchFileSnapshot in src/assertions/snapshots.ts wrote to the caller-supplied filePath with fs.mkdirSync and fs.writeFileSync without checking that the path stayed inside the project, so traversal or an absolute path escaped the workspace whenever snapshot update mode was active. The fix resolves the path against projectRoot, which defaults to process.cwd(), and rejects anything outside it.
Root cause
feat: implement snapshot architecture (client, environment, manager, serializers, matchers)
Fix
fix: add path validation to matchFileSnapshot to prevent path traversal
AI-assisted fix: Claude Code · Sam Caldwell
Case facts
@@ -7,9 +7,109 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; /**- * Serialize a value into a human-readable snapshot string.+ * Configuration for the serialize function's pretty-formatting behavior.+ */+export interface SerializeConfig {+ indent?: number;+ maxDepth?: number;+ min?: boolean;+ printBasicPrototype?: boolean;+}++/**+ * Printer function type passed to custom serializers, allowing them+ * to recursively serialize nested values.+ */+export type PrinterFn = (+ value: unknown,+ config: SerializeConfig,+ indentation: string,+ depth: number,+ refs: Set<unknown>,+) => string;++/**+ * Interface for custom snapshot serializer plugins.+ */+export interface SnapshotSerializer {+ test(value: unknown): boolean;+ serialize(+ value: unknown,+ config: SerializeConfig,+ indentation: string,+ depth: number,+ refs: Set<unknown>,+ printer: PrinterFn,+ ): string;+}++const serializers: SnapshotSerializer[] = [];++/**+ * Register a custom snapshot serializer plugin.+ */+export function addSerializer(serializer: SnapshotSerializer): void {+ serializers.push(serializer);+}++/**+ * Remove a previously registered custom snapshot serializer plugin.+ */+export function removeSerializer(serializer: SnapshotSerializer): void {+ const idx = serializers.indexOf(serializer);+ if (idx !== -1) {+ serializers.splice(idx, 1);+ }+}++/**+ * Get all currently registered custom snapshot serializers.+ */+export function getSerializers(): SnapshotSerializer[] {+ return [...serializers];+}++const DEFAULT_CONFIG: Required<SerializeConfig> = {+ indent: 2,+ maxDepth: 10,+ min: false,+ printBasicPrototype: true,+};++function resolveConfig(config?: SerializeConfig): Required<SerializeConfig> {+ return {+ indent: config?.indent ?? DEFAULT_CONFIG.indent,+ maxDepth: config?.maxDepth ?? DEFAULT_CONFIG.maxDepth,+ min: config?.min ?? DEFAULT_CONFIG.min,+ printBasicPrototype:+ config?.printBasicPrototype ?? DEFAULT_CONFIG.printBasicPrototype,+ };+}++/**+ * Internal recursive printer. */-export function serialize(value: unknown): string {+function printValue(+ value: unknown,@@ -0,0 +1,88 @@+/**+ * Snapshot environment abstraction for reading/writing snapshot files.+ * @module snapshot-environment+ */++import * as fs from 'node:fs';+import * as path from 'node:path';++/**+ * Interface for snapshot file I/O operations.+ * Allows different environments (Node, browser, etc.) to provide+ * their own implementations.+ */+export interface SnapshotEnvironment {+ /** Get the version string for snapshot file headers. */+ getVersion(): string;++ /** Get the header comment for snapshot files. */+ getHeader(): string;++ /** Read the contents of a snapshot file. Returns empty string if not found. */+ readSnapshotFile(filepath: string): Promise<string>;++ /** Write content to a snapshot file, creating directories as needed. */+ saveSnapshotFile(filepath: string, content: string): Promise<void>;++ /** Resolve the snapshot file path for a given test file path. */+ resolveSnapshotPath(testPath: string): string;++ /** Remove a snapshot file. */+ removeSnapshotFile(filepath: string): Promise<void>;++ /** Ensure a directory exists, creating it recursively if needed. */+ prepareDirectory(dirPath: string): Promise<void>;+}++/**+ * Node.js implementation of SnapshotEnvironment using the fs module.+ */+export class NodeSnapshotEnvironment implements SnapshotEnvironment {+ private version: string;++ constructor(version = '1') {+ this.version = version;+ }++ getVersion(): string {+ return this.version;+ }++ getHeader(): string {+ return `// Snapshot v${this.version}`;+ }++ async readSnapshotFile(filepath: string): Promise<string> {+ try {+ return fs.readFileSync(filepath, 'utf-8');+ } catch {+ return '';+ }+ }++ async saveSnapshotFile(filepath: string, content: string): Promise<void> {+ const dir = path.dirname(filepath);+ await this.prepareDirectory(dir);+ fs.writeFileSync(filepath, content, 'utf-8');+ }++ resolveSnapshotPath(testPath: string): string {+ const dir = path.dirname(testPath);+ const basename = path.basename(testPath, path.extname(testPath));+ return path.join(dir, '__snapshots__', `${basename}.snap`);+ }++ async removeSnapshotFile(filepath: string): Promise<void> {+ try {+ fs.unlinkSync(filepath);+ } catch {+ // File may not exist, ignore+ }+ }++ async prepareDirectory(dirPath: string): Promise<void> {+ if (!fs.existsSync(dirPath)) {+ fs.mkdirSync(dirPath, { recursive: true });+ }+ }+}@@ -0,0 +1,181 @@+/**+ * SnapshotClient manages snapshot state for a single test.+ * Handles matching, updating, and tracking snapshot assertions.+ * @module snapshot-client+ */++import { serialize } from './snapshots.js';+import type { SnapshotEnvironment } from './snapshot-environment.js';++/**+ * Summary of snapshot operations for a single client.+ */+export interface SnapshotSummary {+ added: number;+ updated: number;+ matched: number;+ testName: string;+}++/**+ * SnapshotClient manages the lifecycle of snapshot assertions for a test.+ * Call prepareTest() before each test, assert() for each snapshot check,+ * and finishTest() after to collect results.+ */+export class SnapshotClient {+ private testName: string = '';+ private snapshotCount: number = 0;+ private snapshots: Map<string, string> = new Map();+ private added: number = 0;+ private updated: number = 0;+ private matched: number = 0;+ private environment: SnapshotEnvironment;+ private snapshotFilePath: string = '';++ constructor(environment: SnapshotEnvironment) {+ this.environment = environment;+ }++ /**+ * Prepare the client for a new test. Resets the snapshot counter+ * and sets the test name.+ */+ prepareTest(testName: string): void {+ this.testName = testName;+ this.snapshotCount = 0;+ }++ /**+ * Load snapshots from a file into the internal map.+ * Parses the snapshot file format: exports['name'] = `value`;+ */+ async loadSnapshots(filepath: string): Promise<void> {+ this.snapshotFilePath = filepath;+ const content = await this.environment.readSnapshotFile(filepath);+ if (!content) return;++ const regex = /exports\['(.+?)'\]\s*=\s*`([\s\S]*?)`;/g;+ let match: RegExpExecArray | null = regex.exec(content);+ while (match !== null) {+ const name = match[1];+ const value = match[2];+ if (name !== undefined && value !== undefined) {+ this.snapshots.set(name, unescapeFromTemplate(value));+ }+ match = regex.exec(content);+ }+ }++ /**+ * Assert that a received value matches the stored snapshot.+ * If no snapshot exists, it is created (added).+ * If UPDATE_SNAPSHOTS=1 is set, existing snapshots are overwritten (updated).+ */+ assert(received: unknown, snapshotName?: string): void {+ this.snapshotCount++;+ const key =+ snapshotName ?? `${this.testName} ${String(this.snapshotCount)}`;+ const serialized = serialize(received);+ const shouldUpdate = process.env['UPDATE_SNAPSHOTS'] === '1';++ if (!this.snapshots.has(key)) {+ // New snapshot+ this.snapshots.set(key, serialized);+ this.added++;+ return;+ }++ if (shouldUpdate) {+ const existing = this.snapshots.get(key);@@ -729,19 +729,31 @@ export function matchInlineSnapshot( * - If the file doesn't exist and update mode is active, create it. * - If mismatch and update mode is 'all', overwrite the file. */-export function matchFileSnapshot(actual: unknown, filePath: string): void {+export function matchFileSnapshot(+ actual: unknown,+ filePath: string,+ projectRoot?: string,+): void {+ const root = projectRoot ?? process.cwd();+ const resolved = path.resolve(root, filePath);+ if (!resolved.startsWith(root + path.sep) && resolved !== root) {+ throw new Error(+ `File snapshot path must be within the project directory: ${filePath}`,+ );+ }+ const serialized = serialize(actual); const mode = resolveUpdateMode();- const fileExists = fs.existsSync(filePath);+ const fileExists = fs.existsSync(resolved); if (!fileExists) { if (mode === 'all' || mode === 'new') { // Create the file- const dir = path.dirname(filePath);+ const dir = path.dirname(resolved); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); }- fs.writeFileSync(filePath, serialized, 'utf-8');+ fs.writeFileSync(resolved, serialized, 'utf-8'); return; } throw new Error(@@ -750,14 +762,14 @@ export function matchFileSnapshot(actual: unknown, filePath: string): void { ); } - const stored = fs.readFileSync(filePath, 'utf-8');+ const stored = fs.readFileSync(resolved, 'utf-8'); if (serialized === stored) { return; // match } // Mismatch if (mode === 'all') {- fs.writeFileSync(filePath, serialized, 'utf-8');+ fs.writeFileSync(resolved, serialized, 'utf-8'); return; } @@ -35,7 +35,7 @@ describe('toMatchFileSnapshot', () => { const value = { name: 'test', count: 42 }; fs.writeFileSync(filePath, serialize(value), 'utf-8'); try {- matchFileSnapshot(value, filePath);+ matchFileSnapshot(value, filePath, tmpDir); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }@@ -47,7 +47,7 @@ describe('toMatchFileSnapshot', () => { fs.writeFileSync(filePath, '"old value"', 'utf-8'); try { assert.throws(() => {- matchFileSnapshot('new value', filePath);+ matchFileSnapshot('new value', filePath, tmpDir); }, /File snapshot mismatch/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true });@@ -59,7 +59,7 @@ describe('toMatchFileSnapshot', () => { const filePath = path.join(tmpDir, 'subdir', 'new.snap'); setUpdateMode('all'); try {- matchFileSnapshot({ hello: 'world' }, filePath);+ matchFileSnapshot({ hello: 'world' }, filePath, tmpDir); assert.ok(fs.existsSync(filePath), 'file should be created'); const content = fs.readFileSync(filePath, 'utf-8'); assert.strictEqual(content, serialize({ hello: 'world' }));@@ -74,7 +74,7 @@ describe('toMatchFileSnapshot', () => { fs.writeFileSync(filePath, '"old"', 'utf-8'); setUpdateMode('all'); try {- matchFileSnapshot('new', filePath);+ matchFileSnapshot('new', filePath, tmpDir); const content = fs.readFileSync(filePath, 'utf-8'); assert.strictEqual(content, '"new"'); } finally {@@ -87,12 +87,43 @@ describe('toMatchFileSnapshot', () => { const filePath = path.join(tmpDir, 'nonexistent.snap'); try { assert.throws(() => {- matchFileSnapshot('value', filePath);+ matchFileSnapshot('value', filePath, tmpDir); }, /File snapshot not found/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } });++ it('should reject absolute paths outside the project root', () => {+ setUpdateMode('all');+ assert.throws(() => {+ matchFileSnapshot('payload', '/tmp/outside-project.snap');+ }, /File snapshot path must be within the project directory/);+ });++ it('should reject relative paths that traverse above project root', () => {+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-'));+ try {+ setUpdateMode('all');+ assert.throws(() => {+ matchFileSnapshot('payload', '../../../tmp/evil.snap', tmpDir);+ }, /File snapshot path must be within the project directory/);+ } finally {+ fs.rmSync(tmpDir, { recursive: true, force: true });+ }+ });++ it('should allow paths within the project directory', () => {+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-'));+ const filePath = path.join(tmpDir, 'safe.snap');+ setUpdateMode('all');+ try {+ matchFileSnapshot('safe value', filePath, tmpDir);+ assert.ok(fs.existsSync(filePath), 'file should be created');+ } finally {+ fs.rmSync(tmpDir, { recursive: true, force: true });+ }+ }); }); describe('auto-incrementing snapshot names', () => {AI-assisted change a3add8cf85fae9f7894b274272d71694744dcf6639922c0630f41d99e0244a76 · Fix b705435b3bd5ccbdf1dac596031ecf8c4db4a9c6ba29b9208024077b5222be24