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.
This commit is contained in:
Anso
2026-05-24 15:39:27 -04:00
committed by GitHub
parent 7c84969b31
commit 82a4e94589
2 changed files with 105 additions and 0 deletions
@@ -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);
});
});
+38
View File
@@ -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<FileEntry[]> {
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<FileEntry[]>;
@@ -42,6 +70,7 @@ export async function readStackFile(
stackName: string,
relPath: string
): Promise<FileContentResult> {
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<FileContentResult>;
@@ -51,6 +80,7 @@ export async function downloadStackFile(
stackName: string,
relPath: string
): Promise<Response> {
assertSafeRelPath(relPath);
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
}
@@ -60,6 +90,7 @@ export async function uploadStackFile(
file: File,
options?: { localOnly?: boolean }
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<EntryPermissions> {
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<EntryPermissions>;
@@ -164,6 +201,7 @@ export async function setStackEntryPermissions(
relPath: string,
mode: number
): Promise<void> {
assertSafeRelPath(relPath);
const res = await apiFetch(
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
{ method: 'PUT', body: JSON.stringify({ mode }) }