mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(server): add safe directory creation to path service
Introduces PathService.createDirectory with comprehensive path validation to prevent directory traversal and symlink attacks. It validates parent paths against a blocked list, ensures the parent is a real directory, and blocks unsafe characters in folder names. Filesystem errors like EEXIST and EACCES are mapped to standard HTTP exceptions to provide clear feedback to the client. Closes #332
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { lstat, readdir } from 'fs/promises';
|
||||
import type { CreateFolderResult, DirectoryEntry } from '@bookorbit/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { lstat, mkdir, readdir } from 'fs/promises';
|
||||
import { join, resolve, sep } from 'path';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
|
||||
|
||||
@Injectable()
|
||||
export class PathService {
|
||||
async listDirectories(rawPath: string): Promise<{ name: string; path: string }[]> {
|
||||
private readonly logger = new Logger(PathService.name);
|
||||
|
||||
async listDirectories(rawPath: string): Promise<DirectoryEntry[]> {
|
||||
const resolved = resolve(rawPath || '/');
|
||||
if (BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep))) {
|
||||
if (this.isBlocked(resolved)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
@@ -16,7 +29,7 @@ export class PathService {
|
||||
if (rootStat.isSymbolicLink()) return [];
|
||||
|
||||
const entries = await readdir(resolved, { withFileTypes: true }); // codeql[js/path-injection]
|
||||
const dirs: { name: string; path: string }[] = [];
|
||||
const dirs: DirectoryEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
@@ -33,4 +46,83 @@ export class PathService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createDirectory(rawParent: string, rawName: string): Promise<CreateFolderResult> {
|
||||
const name = this.assertSafeName(rawName);
|
||||
const parent = resolve(rawParent || '/');
|
||||
if (this.isBlocked(parent)) {
|
||||
throw new ForbiddenException('Cannot create a folder in this location');
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[path.create_folder] [start] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" - create folder started`);
|
||||
try {
|
||||
const parentStat = await lstat(parent); // codeql[js/path-injection]
|
||||
if (parentStat.isSymbolicLink()) throw new ForbiddenException('Cannot create a folder under a symbolic link');
|
||||
if (!parentStat.isDirectory()) throw new BadRequestException('Parent path is not a directory');
|
||||
|
||||
const target = join(parent, name);
|
||||
const prefix = parent.endsWith(sep) ? parent : parent + sep;
|
||||
if (!resolve(target).startsWith(prefix)) {
|
||||
throw new BadRequestException('Invalid folder name');
|
||||
}
|
||||
|
||||
await mkdir(target); // codeql[js/path-injection]
|
||||
this.logger.log(`[path.create_folder] [end] path="${sanitizeLogValue(target)}" durationMs=${Date.now() - startedAt} - create folder completed`);
|
||||
return { name, path: target };
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[path.create_folder] [fail] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" durationMs=${Date.now() - startedAt} errorClass=${getErrorClass(error)} error="${sanitizeLogValue(getErrorMessage(error))}" - create folder failed`,
|
||||
);
|
||||
throw this.mapCreateError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private isBlocked(resolved: string): boolean {
|
||||
return BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep));
|
||||
}
|
||||
|
||||
private assertSafeName(rawName: string): string {
|
||||
const name = (rawName ?? '').trim();
|
||||
if (!name) throw new BadRequestException('Folder name is required');
|
||||
if (name.includes('/') || name.includes('\\') || name.includes(sep)) {
|
||||
throw new BadRequestException('Folder name cannot contain path separators');
|
||||
}
|
||||
if (name === '.' || name === '..' || name.startsWith('.')) {
|
||||
throw new BadRequestException('Folder name cannot start with a dot');
|
||||
}
|
||||
if (name.includes('\0')) throw new BadRequestException('Folder name contains invalid characters');
|
||||
return name;
|
||||
}
|
||||
|
||||
private mapCreateError(error: unknown): HttpException {
|
||||
if (error instanceof HttpException) return error;
|
||||
switch (getErrorCode(error)) {
|
||||
case 'EEXIST':
|
||||
return new ConflictException('A folder with that name already exists');
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return new ForbiddenException('Permission denied');
|
||||
case 'EROFS':
|
||||
return new BadRequestException('The filesystem is read-only');
|
||||
case 'ENOENT':
|
||||
return new BadRequestException('Parent folder no longer exists');
|
||||
default:
|
||||
return new InternalServerErrorException('Could not create the folder');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
function getErrorClass(error: unknown): string {
|
||||
return error instanceof Error ? error.constructor.name : 'Error';
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user