From 82a4e94589d24468eac06f3e083aa90acfc9d109 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 24 May 2026 15:39:27 -0400 Subject: [PATCH] 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. --- .../src/lib/__tests__/stackFilesApi.test.ts | 67 +++++++++++++++++++ frontend/src/lib/stackFilesApi.ts | 38 +++++++++++ 2 files changed, 105 insertions(+) create mode 100644 frontend/src/lib/__tests__/stackFilesApi.test.ts diff --git a/frontend/src/lib/__tests__/stackFilesApi.test.ts b/frontend/src/lib/__tests__/stackFilesApi.test.ts new file mode 100644 index 00000000..180961b6 --- /dev/null +++ b/frontend/src/lib/__tests__/stackFilesApi.test.ts @@ -0,0 +1,67 @@ +/** + * 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); + }); +}); diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index 56d1a64c..79676379 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -1,5 +1,32 @@ import { apiFetch } from './api'; +/** + * Mirrors backend/src/utils/validation.ts::isValidRelativeStackPath. Client + * defense-in-depth: the backend rejects path-traversal attempts, but catching + * them client-side avoids a wasted round trip and surfaces a clearer error + * to the user. Also guards against a future regression on the server side. + * + * Allow: the empty string (means the stack root) and POSIX-style relative + * paths with no traversal segments. Reject: absolute paths, drive letters, + * backslashes, double slashes, NUL bytes, and any segment that is `.` or `..`. + */ +export function isClientSafeRelPath(rel: string): boolean { + if (typeof rel !== 'string') return false; + if (rel === '') return true; + if (rel.includes('\0')) return false; + if (rel.includes('\\')) return false; + if (/^[a-zA-Z]:/.test(rel) || rel.startsWith('/')) return false; + if (rel.includes('//')) return false; + const segments = rel.split('/'); + return !segments.some(seg => seg === '..' || seg === '.'); +} + +function assertSafeRelPath(rel: string, label = 'path'): void { + if (!isClientSafeRelPath(rel)) { + throw new Error(`Invalid ${label}: must be a relative path inside the stack directory`); + } +} + export interface FileEntry { name: string; type: 'file' | 'directory' | 'symlink'; @@ -33,6 +60,7 @@ export async function listStackDirectory( stackName: string, relPath: string ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`)); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; @@ -42,6 +70,7 @@ export async function readStackFile( stackName: string, relPath: string ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch(stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`)); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; @@ -51,6 +80,7 @@ export async function downloadStackFile( stackName: string, relPath: string ): Promise { + assertSafeRelPath(relPath); return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`)); } @@ -60,6 +90,7 @@ export async function uploadStackFile( file: File, options?: { localOnly?: boolean } ): Promise { + assertSafeRelPath(targetDir, 'target directory'); const fd = new FormData(); fd.append('file', file, file.name); @@ -103,6 +134,7 @@ export async function writeStackFile( relPath: string, content: string ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch( stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`), { method: 'PUT', body: JSON.stringify({ content }) } @@ -115,6 +147,7 @@ export async function deleteStackPath( relPath: string, recursive?: boolean ): Promise { + assertSafeRelPath(relPath); const qs = recursive ? `path=${encodeURIComponent(relPath)}&recursive=1` : `path=${encodeURIComponent(relPath)}`; @@ -126,6 +159,7 @@ export async function mkdirStackPath( stackName: string, relPath: string ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch( stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`), { method: 'POST', body: JSON.stringify({}) } @@ -138,6 +172,8 @@ export async function renameStackPath( fromRel: string, toRel: string ): Promise { + assertSafeRelPath(fromRel, 'source path'); + assertSafeRelPath(toRel, 'destination path'); const res = await apiFetch( stackFilesUrl(stackName, '/rename'), { method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) } @@ -154,6 +190,7 @@ export async function getStackEntryPermissions( stackName: string, relPath: string ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`)); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; @@ -164,6 +201,7 @@ export async function setStackEntryPermissions( relPath: string, mode: number ): Promise { + assertSafeRelPath(relPath); const res = await apiFetch( stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`), { method: 'PUT', body: JSON.stringify({ mode }) }