Files
sencho/frontend/src/lib/__tests__/stackFilesApi.test.ts
T
Anso 82a4e94589 chore(stack-files): client-side path-traversal guard in stackFilesApi (#1190)
The backend already rejects path-traversal attempts through
isValidRelativeStackPath, so the server side is safe today. Adding a
client-side mirror is defense-in-depth: it shortens the failure loop
(no wasted round trip) and protects against a future server-side
regression that loosens validation.

Adds isClientSafeRelPath in frontend/src/lib/stackFilesApi.ts mirroring
the backend predicate (rejects absolute paths, drive letters,
backslashes, NUL bytes, double slashes, and any segment that is `.`
or `..`). Wraps every export that accepts a relPath / targetDir /
fromRel / toRel argument with assertSafeRelPath, throwing a clear
Error before the fetch is issued.

12 unit tests cover the predicate (POSIX accepts, traversal rejects,
Windows drive letters, backslashes, NUL bytes, non-string inputs).
Frontend suite stays at 288/288.
2026-05-24 15:39:27 -04:00

68 lines
2.3 KiB
TypeScript

/**
* Unit tests for the client-side path-traversal guard added to
* stackFilesApi exports. The guard mirrors
* backend/src/utils/validation.ts::isValidRelativeStackPath so a
* malicious or buggy caller cannot slip a `..` segment past the
* client before it would otherwise be caught by the server.
*/
import { describe, it, expect } from 'vitest';
import { isClientSafeRelPath } from '../stackFilesApi';
describe('isClientSafeRelPath', () => {
it('accepts the empty string (means the stack root)', () => {
expect(isClientSafeRelPath('')).toBe(true);
});
it('accepts a simple file name', () => {
expect(isClientSafeRelPath('compose.yaml')).toBe(true);
});
it('accepts a nested POSIX path', () => {
expect(isClientSafeRelPath('config/redis/redis.conf')).toBe(true);
});
it('accepts a hidden file', () => {
expect(isClientSafeRelPath('.env')).toBe(true);
});
it('rejects parent-directory traversal', () => {
expect(isClientSafeRelPath('..')).toBe(false);
expect(isClientSafeRelPath('../etc/passwd')).toBe(false);
expect(isClientSafeRelPath('config/../../../etc/passwd')).toBe(false);
});
it('rejects same-directory segment', () => {
expect(isClientSafeRelPath('./config')).toBe(false);
expect(isClientSafeRelPath('config/./redis.conf')).toBe(false);
});
it('rejects absolute POSIX paths', () => {
expect(isClientSafeRelPath('/etc/passwd')).toBe(false);
expect(isClientSafeRelPath('/')).toBe(false);
});
it('rejects Windows drive-letter paths', () => {
expect(isClientSafeRelPath('C:/Windows/System32')).toBe(false);
expect(isClientSafeRelPath('d:foo')).toBe(false);
});
it('rejects backslashes', () => {
expect(isClientSafeRelPath('config\\redis.conf')).toBe(false);
expect(isClientSafeRelPath('..\\..\\etc\\passwd')).toBe(false);
});
it('rejects NUL bytes', () => {
expect(isClientSafeRelPath('foo\0bar')).toBe(false);
});
it('rejects double slashes', () => {
expect(isClientSafeRelPath('config//redis.conf')).toBe(false);
});
it('rejects non-string input', () => {
expect(isClientSafeRelPath(undefined as unknown as string)).toBe(false);
expect(isClientSafeRelPath(null as unknown as string)).toBe(false);
expect(isClientSafeRelPath(42 as unknown as string)).toBe(false);
});
});