mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +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';
|
||||
|
||||
/**
|
||||
* 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 }) }
|
||||
|
||||
Reference in New Issue
Block a user