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
+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 }) }