feat: add NasFileManager service with security-hardened file operations

TypeScript port of PHP NasFileManager with symlink rejection,
path traversal protection, MIME validation via file-type, and
blocked extension checking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 10:16:36 +01:00
parent 373ea82279
commit ff26dc497d
3 changed files with 834 additions and 0 deletions

View File

@@ -0,0 +1,618 @@
import fs from 'fs';
import path from 'path';
import { config } from '../config/env';
const FileType = require('file-type') as typeof import('file-type');
const BLOCKED_EXTENSIONS = new Set([
'exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1',
'vbs', 'vbe', 'js', 'ws', 'wsf', 'scr', 'pif', 'jar', 'reg',
]);
const SUSPICIOUS_MIMES = [
'application/x-executable',
'application/x-msdos-program',
'application/x-dosexec',
'application/x-msdownload',
];
const MIME_MAP: Record<string, string> = {
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
tar: 'application/x-tar',
gz: 'application/gzip',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
webp: 'image/webp',
ico: 'image/x-icon',
tif: 'image/tiff',
tiff: 'image/tiff',
mp3: 'audio/mpeg',
wav: 'audio/wav',
mp4: 'video/mp4',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
txt: 'text/plain',
csv: 'text/csv',
html: 'text/html',
htm: 'text/html',
xml: 'application/xml',
json: 'application/json',
dwg: 'application/acad',
dxf: 'application/dxf',
step: 'application/step',
stp: 'application/step',
iges: 'application/iges',
igs: 'application/iges',
};
interface FileItem {
name: string;
type: 'file' | 'folder';
modified: string;
is_symlink: boolean;
link_target?: string;
size?: number;
size_formatted?: string;
extension?: string;
item_count?: number;
}
interface ListFilesResult {
path: string;
items: FileItem[];
breadcrumb: string[];
full_path: string;
}
interface DownloadResult {
filePath: string;
fileName: string;
mime: string;
}
export class NasFileManager {
private readonly basePath: string;
constructor() {
this.basePath = path.resolve(config.nas.path).replace(/\\/g, '/');
}
public isConfigured(): boolean {
if (!this.basePath) return false;
try {
return fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory();
} catch {
return false;
}
}
public createProjectFolder(projectNumber: string, projectName: string): boolean {
if (!this.isConfigured()) return false;
const folderName = this.buildFolderName(projectNumber, projectName);
const fullPath = this.basePath + '/' + folderName;
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
return true;
}
try {
fs.mkdirSync(fullPath, { recursive: true, mode: 0o775 });
return true;
} catch {
return false;
}
}
public async deleteProjectFolder(projectNumber: string): Promise<boolean> {
if (!this.isConfigured()) return false;
const folderPath = this.findProjectFolder(projectNumber);
if (folderPath === null) return true;
try {
await fs.promises.rm(folderPath, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
public projectFolderExists(projectNumber: string): boolean {
return this.findProjectFolder(projectNumber) !== null;
}
public renameProjectFolder(projectNumber: string, newName: string): boolean {
if (!this.isConfigured()) return false;
const currentPath = this.findProjectFolder(projectNumber);
if (currentPath === null) return false;
const newFolderName = this.buildFolderName(projectNumber, newName);
const newPath = this.basePath + '/' + newFolderName;
if (currentPath === newPath) return true;
try {
fs.renameSync(currentPath, newPath);
return true;
} catch {
return false;
}
}
public listFiles(projectNumber: string, subPath: string = ''): ListFilesResult | null {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (dirPath === null) return null;
try {
const stat = fs.lstatSync(dirPath);
if (!stat.isDirectory()) return null;
} catch {
return null;
}
let entries: string[];
try {
entries = fs.readdirSync(dirPath);
} catch {
return null;
}
const items: FileItem[] = [];
for (const entry of entries) {
const fullPath = dirPath + '/' + entry;
let lstat: fs.Stats;
try {
lstat = fs.lstatSync(fullPath);
} catch {
continue;
}
const isLink = lstat.isSymbolicLink();
// For symlinks, we need to check if target is dir
let isDir: boolean;
if (isLink) {
try {
isDir = fs.statSync(fullPath).isDirectory();
} catch {
isDir = false;
}
} else {
isDir = lstat.isDirectory();
}
const modified = lstat.mtime;
const modifiedStr =
modified.getFullYear() +
'-' + String(modified.getMonth() + 1).padStart(2, '0') +
'-' + String(modified.getDate()).padStart(2, '0') +
' ' + String(modified.getHours()).padStart(2, '0') +
':' + String(modified.getMinutes()).padStart(2, '0');
const item: FileItem = {
name: entry,
type: isDir ? 'folder' : 'file',
modified: modifiedStr,
is_symlink: isLink,
};
if (isLink) {
try {
const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, '\\');
} catch {
// ignore
}
}
if (isDir) {
item.item_count = this.countItems(fullPath);
} else {
const size = lstat.size;
item.size = size;
item.size_formatted = this.formatFileSize(size);
item.extension = path.extname(entry).slice(1).toLowerCase();
}
items.push(item);
}
// Sort: folders first, then files, both alphabetically (natural sort)
items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
const breadcrumb: string[] = [''];
if (subPath !== '') {
const parts = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').split('/');
for (const part of parts) {
breadcrumb.push(part);
}
}
// Real path on disk for UI display
let realDirPath: string;
try {
realDirPath = fs.realpathSync(dirPath);
} catch {
realDirPath = dirPath;
}
return {
path: subPath,
items,
breadcrumb,
full_path: realDirPath.replace(/\//g, '\\'),
};
}
public async uploadFile(
projectNumber: string,
subPath: string,
fileBuffer: Buffer,
fileName: string,
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return 'Cílová složka neexistuje';
}
if (fileBuffer.length > config.nas.maxUploadSize) {
const maxMb = Math.round(config.nas.maxUploadSize / 1048576);
return `Soubor je příliš velký (max ${maxMb} MB)`;
}
const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName);
if (safeName === '') {
return 'Neplatný název souboru';
}
const ext = path.extname(safeName).slice(1).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
return 'Tento typ souboru není povolen';
}
// MIME validation via file-type
try {
const typeResult = await FileType.fromBuffer(fileBuffer);
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
return 'Obsah souboru neodpovídá jeho příponě';
}
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + '/' + safeName;
// If file exists, append counter
if (fs.existsSync(destPath)) {
const base = path.basename(safeName, ext ? '.' + ext : '');
let counter = 1;
do {
safeName = base + '_' + counter + (ext ? '.' + ext : '');
destPath = dirPath + '/' + safeName;
counter++;
} while (fs.existsSync(destPath));
}
try {
fs.writeFileSync(destPath, fileBuffer);
} catch {
return 'Nepodařilo se uložit soubor';
}
return null;
}
public downloadFile(
projectNumber: string,
filePath: string,
): DownloadResult | null {
const fullPath = this.resolveProjectPath(projectNumber, filePath);
if (fullPath === null) return null;
try {
const stat = fs.lstatSync(fullPath);
if (!stat.isFile()) return null;
} catch {
return null;
}
const fileName = path.basename(fullPath);
const ext = path.extname(fileName).slice(1).toLowerCase();
const mime = MIME_MAP[ext] || 'application/octet-stream';
return {
filePath: fullPath,
fileName,
mime,
};
}
public async deleteItem(projectNumber: string, filePath: string): Promise<string | null> {
if (filePath === '' || filePath === '/') {
return 'Nelze smazat kořenovou složku projektu';
}
const fullPath = this.resolveProjectPath(projectNumber, filePath);
if (fullPath === null) {
return 'Neplatná cesta';
}
if (!fs.existsSync(fullPath)) {
return 'Soubor nebo složka neexistuje';
}
let isDir: boolean;
try {
isDir = fs.lstatSync(fullPath).isDirectory();
} catch {
return 'Neplatná cesta';
}
try {
if (isDir) {
await fs.promises.rm(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
}
} catch {
return isDir
? 'Nepodařilo se smazat složku'
: 'Nepodařilo se smazat soubor';
}
return null;
}
public moveItem(projectNumber: string, fromPath: string, toPath: string): string | null {
if (fromPath === '' || fromPath === '/') {
return 'Nelze přesunout kořenovou složku';
}
const fullFrom = this.resolveProjectPath(projectNumber, fromPath);
const fullTo = this.resolveProjectPath(projectNumber, toPath);
if (fullFrom === null || fullTo === null) {
return 'Neplatná cesta';
}
if (!fs.existsSync(fullFrom)) {
return 'Zdrojový soubor neexistuje';
}
// Case-insensitive FS (Windows) — allow case-only rename
const sameFile =
fullFrom.replace(/\\/g, '/').toLowerCase() ===
fullTo.replace(/\\/g, '/').toLowerCase();
if (fs.existsSync(fullTo) && !sameFile) {
return 'Cílový soubor již existuje';
}
// Validate target name
const targetName = path.basename(toPath);
if (this.sanitizeFilename(targetName) !== targetName) {
return 'Neplatný cílový název';
}
try {
fs.renameSync(fullFrom, fullTo);
} catch (err: unknown) {
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EXDEV') {
return 'Přesun mezi různými disky není podporován';
}
return 'Nepodařilo se přesunout soubor';
}
return null;
}
public createFolder(projectNumber: string, subPath: string, folderName: string): string | null {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return 'Nadřazená složka neexistuje';
}
const safeName = this.sanitizeFilename(folderName);
if (safeName === '') {
return 'Neplatný název složky';
}
const newPath = dirPath + '/' + safeName;
if (fs.existsSync(newPath)) {
return 'Složka s tímto názvem již existuje';
}
try {
fs.mkdirSync(newPath, { mode: 0o775 });
} catch {
return 'Nepodařilo se vytvořit složku';
}
return null;
}
public sanitizeFilename(name: string): string {
let safe = path.basename(name);
// Strip control chars and special chars
safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, '');
safe = safe.replace(/^[. ]+|[. ]+$/g, '');
if ([...safe].length > 255) {
const ext = path.extname(safe).slice(1);
const base = path.basename(safe, ext ? '.' + ext : '');
const maxBase = 250 - [...ext].length;
const trimmedBase = [...base].slice(0, maxBase).join('');
safe = ext ? trimmedBase + '.' + ext : trimmedBase;
}
return safe;
}
// --- Private helpers ---
private findProjectFolder(projectNumber: string): string | null {
if (!this.isConfigured()) return null;
let entries: string[];
try {
entries = fs.readdirSync(this.basePath);
} catch {
return null;
}
const prefix = projectNumber + '_';
for (const entry of entries) {
if (entry.startsWith(prefix)) {
const fullPath = this.basePath + '/' + entry;
try {
if (fs.statSync(fullPath).isDirectory()) {
return fullPath;
}
} catch {
continue;
}
}
}
return null;
}
private buildFolderName(projectNumber: string, projectName: string): string {
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, '');
safe = safe.trim().replace(/ /g, '_');
safe = safe.replace(/_+/g, '_');
if ([...safe].length > 200) {
safe = [...safe].slice(0, 200).join('');
}
return projectNumber + '_' + safe;
}
private resolveProjectPath(projectNumber: string, subPath: string): string | null {
const folderPath = this.findProjectFolder(projectNumber);
if (folderPath === null) return null;
if (subPath === '' || subPath === '/') {
return folderPath;
}
// Basic path traversal protection
if (subPath.includes('\0') || subPath.includes('..')) {
return null;
}
// Normalize separators and trim
const normalized = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, '/');
// Verify candidate is within project folder
const normalFolder = folderPath.replace(/\\/g, '/');
if (!candidate.startsWith(normalFolder + '/') && candidate !== normalFolder) {
return null;
}
// Check for symlinks in path components
if (fs.existsSync(candidate)) {
if (!this.walkAndRejectSymlinks(candidate, normalFolder)) {
return null;
}
} else {
// For new files/folders — check parent
const parentDir = path.dirname(candidate);
if (fs.existsSync(parentDir)) {
if (!this.walkAndRejectSymlinks(parentDir, normalFolder)) {
return null;
}
}
}
return candidate;
}
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
const normalFull = fullPath.replace(/\\/g, '/');
const normalBase = basePath.replace(/\\/g, '/');
// Get the relative portion after basePath
if (!normalFull.startsWith(normalBase)) {
return false;
}
const relative = normalFull.slice(normalBase.length);
if (!relative) return true; // same as base
const parts = relative.split('/').filter(Boolean);
let current = normalBase;
for (const part of parts) {
current = current + '/' + part;
try {
const lstat = fs.lstatSync(current);
if (lstat.isSymbolicLink()) {
return false;
}
} catch {
// Path component doesn't exist yet, that's OK
break;
}
}
return true;
}
private countItems(dirPath: string): number {
try {
const entries = fs.readdirSync(dirPath);
return entries.length;
} catch {
return 0;
}
}
private formatFileSize(bytes: number): string {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1048576) {
return (Math.round((bytes / 1024) * 10) / 10) + ' KB';
}
if (bytes < 1073741824) {
return (Math.round((bytes / 1048576) * 10) / 10) + ' MB';
}
return (Math.round((bytes / 1073741824) * 10) / 10) + ' GB';
}
private isSuspiciousMime(mime: string, ext: string): boolean {
if (SUSPICIOUS_MIMES.includes(mime)) {
return true;
}
// PHP files
if (mime.includes('php') || mime.includes('x-httpd')) {
return true;
}
return false;
}
}