mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,32 @@
|
|||||||
import { apiFetch } from './api';
|
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 {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'file' | 'directory' | 'symlink';
|
type: 'file' | 'directory' | 'symlink';
|
||||||
@@ -33,6 +60,7 @@ export async function listStackDirectory(
|
|||||||
stackName: string,
|
stackName: string,
|
||||||
relPath: string
|
relPath: string
|
||||||
): Promise<FileEntry[]> {
|
): Promise<FileEntry[]> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`));
|
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`));
|
||||||
if (!res.ok) throw new Error(await parseApiError(res));
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
return res.json() as Promise<FileEntry[]>;
|
return res.json() as Promise<FileEntry[]>;
|
||||||
@@ -42,6 +70,7 @@ export async function readStackFile(
|
|||||||
stackName: string,
|
stackName: string,
|
||||||
relPath: string
|
relPath: string
|
||||||
): Promise<FileContentResult> {
|
): Promise<FileContentResult> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`));
|
const res = await apiFetch(stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`));
|
||||||
if (!res.ok) throw new Error(await parseApiError(res));
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
return res.json() as Promise<FileContentResult>;
|
return res.json() as Promise<FileContentResult>;
|
||||||
@@ -51,6 +80,7 @@ export async function downloadStackFile(
|
|||||||
stackName: string,
|
stackName: string,
|
||||||
relPath: string
|
relPath: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
|
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +90,7 @@ export async function uploadStackFile(
|
|||||||
file: File,
|
file: File,
|
||||||
options?: { localOnly?: boolean }
|
options?: { localOnly?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(targetDir, 'target directory');
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file, file.name);
|
fd.append('file', file, file.name);
|
||||||
|
|
||||||
@@ -103,6 +134,7 @@ export async function writeStackFile(
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
content: string
|
content: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
|
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
|
||||||
{ method: 'PUT', body: JSON.stringify({ content }) }
|
{ method: 'PUT', body: JSON.stringify({ content }) }
|
||||||
@@ -115,6 +147,7 @@ export async function deleteStackPath(
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
recursive?: boolean
|
recursive?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const qs = recursive
|
const qs = recursive
|
||||||
? `path=${encodeURIComponent(relPath)}&recursive=1`
|
? `path=${encodeURIComponent(relPath)}&recursive=1`
|
||||||
: `path=${encodeURIComponent(relPath)}`;
|
: `path=${encodeURIComponent(relPath)}`;
|
||||||
@@ -126,6 +159,7 @@ export async function mkdirStackPath(
|
|||||||
stackName: string,
|
stackName: string,
|
||||||
relPath: string
|
relPath: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`),
|
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`),
|
||||||
{ method: 'POST', body: JSON.stringify({}) }
|
{ method: 'POST', body: JSON.stringify({}) }
|
||||||
@@ -138,6 +172,8 @@ export async function renameStackPath(
|
|||||||
fromRel: string,
|
fromRel: string,
|
||||||
toRel: string
|
toRel: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(fromRel, 'source path');
|
||||||
|
assertSafeRelPath(toRel, 'destination path');
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
stackFilesUrl(stackName, '/rename'),
|
stackFilesUrl(stackName, '/rename'),
|
||||||
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
|
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
|
||||||
@@ -154,6 +190,7 @@ export async function getStackEntryPermissions(
|
|||||||
stackName: string,
|
stackName: string,
|
||||||
relPath: string
|
relPath: string
|
||||||
): Promise<EntryPermissions> {
|
): Promise<EntryPermissions> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`));
|
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`));
|
||||||
if (!res.ok) throw new Error(await parseApiError(res));
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
return res.json() as Promise<EntryPermissions>;
|
return res.json() as Promise<EntryPermissions>;
|
||||||
@@ -164,6 +201,7 @@ export async function setStackEntryPermissions(
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
mode: number
|
mode: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertSafeRelPath(relPath);
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
|
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
|
||||||
{ method: 'PUT', body: JSON.stringify({ mode }) }
|
{ method: 'PUT', body: JSON.stringify({ mode }) }
|
||||||
|
|||||||
Reference in New Issue
Block a user