diff --git a/src/utils/secureFile.ts b/src/utils/secureFile.ts new file mode 100644 index 0000000..f50c2bc --- /dev/null +++ b/src/utils/secureFile.ts @@ -0,0 +1,559 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, renameSync } from 'node:fs' +import { join, dirname, normalize, resolve, extname } from 'node:path' +import { homedir } from 'node:os' + +/** + * 安全文件系统操作服务 + * 解决文件系统操作中缺少适当验证和错误处理的问题 + */ +export class SecureFileService { + private static instance: SecureFileService + private allowedBasePaths: Set + private maxFileSize: number + private allowedExtensions: Set + + private constructor() { + // 允许的基础路径 + this.allowedBasePaths = new Set([ + process.cwd(), + homedir(), + '/tmp', + '/var/tmp' + ]) + + // 默认最大文件大小 (10MB) + this.maxFileSize = 10 * 1024 * 1024 + + // 允许的文件扩展名 + this.allowedExtensions = new Set([ + '.txt', '.md', '.json', '.js', '.ts', '.tsx', '.jsx', + '.yaml', '.yml', '.toml', '.ini', '.env', '.log', + '.html', '.css', '.scss', '.less', '.xml', '.csv', + '.py', '.go', '.rs', '.java', '.cpp', '.c', '.h', + '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', + '.dockerfile', '.gitignore', '.npmignore', '.eslintignore' + ]) + } + + public static getInstance(): SecureFileService { + if (!SecureFileService.instance) { + SecureFileService.instance = new SecureFileService() + } + return SecureFileService.instance + } + + /** + * 验证文件路径是否安全 + * @param filePath 文件路径 + * @returns 验证结果 + */ + public validateFilePath(filePath: string): { isValid: boolean; normalizedPath: string; error?: string } { + try { + // 规范化路径 + const normalizedPath = normalize(filePath) + + // 检查路径长度 + if (normalizedPath.length > 4096) { + return { + isValid: false, + normalizedPath, + error: 'Path too long (max 4096 characters)' + } + } + + // 检查是否包含路径遍历字符 + if (normalizedPath.includes('..') || normalizedPath.includes('~')) { + return { + isValid: false, + normalizedPath, + error: 'Path contains traversal characters' + } + } + + // 检查是否包含可疑的字符序列 + const suspiciousPatterns = [ + /\.\./, // 父目录 + /~/, // 用户目录 + /\$\{/, // 环境变量 + /`/, // 命令执行 + /\|/, // 管道符 + /;/, // 命令分隔符 + /&/, // 后台执行 + />/, // 输出重定向 + / { + return absolutePath.startsWith(basePath) + }) + + if (!isInAllowedPath) { + return { + isValid: false, + normalizedPath, + error: 'Path is outside allowed directories' + } + } + + return { isValid: true, normalizedPath: absolutePath } + } catch (error) { + return { + isValid: false, + normalizedPath: filePath, + error: `Path validation failed: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 安全地检查文件是否存在 + * @param filePath 文件路径 + * @returns 文件是否存在 + */ + public safeExists(filePath: string): boolean { + const validation = this.validateFilePath(filePath) + if (!validation.isValid) { + return false + } + + try { + return existsSync(validation.normalizedPath) + } catch (error) { + return false + } + } + + /** + * 安全地读取文件 + * @param filePath 文件路径 + * @param options 读取选项 + * @returns 读取结果 + */ + public safeReadFile( + filePath: string, + options: { + encoding?: BufferEncoding; + maxFileSize?: number; + allowedExtensions?: string[]; + checkFileExtension?: boolean; + } = {} + ): { success: boolean; content?: string | Buffer; error?: string; stats?: any } { + const validation = this.validateFilePath(filePath) + if (!validation.isValid) { + return { success: false, error: validation.error } + } + + try { + const normalizedPath = validation.normalizedPath + + // 检查文件扩展名(如果启用) + if (options.checkFileExtension !== false) { + const ext = extname(normalizedPath).toLowerCase() + const allowedExts = options.allowedExtensions || + Array.from(this.allowedExtensions) + + if (allowedExts.length > 0 && !allowedExts.includes(ext)) { + return { + success: false, + error: `File extension '${ext}' is not allowed` + } + } + } + + // 检查文件是否存在 + if (!existsSync(normalizedPath)) { + return { success: false, error: 'File does not exist' } + } + + // 获取文件信息 + const stats = statSync(normalizedPath) + const maxSize = options.maxFileSize || this.maxFileSize + + // 检查文件大小 + if (stats.size > maxSize) { + return { + success: false, + error: `File too large (${stats.size} bytes, max ${maxSize} bytes)` + } + } + + // 检查文件类型 + if (!stats.isFile()) { + return { success: false, error: 'Path is not a file' } + } + + // 检查文件权限 + if ((stats.mode & parseInt('400', 8)) === 0) { // 检查读权限 + return { success: false, error: 'No read permission' } + } + + // 读取文件内容 + const content = readFileSync(normalizedPath, { + encoding: options.encoding || 'utf8' + }) + + return { + success: true, + content, + stats: { + size: stats.size, + mtime: stats.mtime, + atime: stats.atime, + mode: stats.mode + } + } + } catch (error) { + return { + success: false, + error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 安全地写入文件 + * @param filePath 文件路径 + * @param content 文件内容 + * @param options 写入选项 + * @returns 写入结果 + */ + public safeWriteFile( + filePath: string, + content: string | Buffer, + options: { + encoding?: BufferEncoding; + createDirectory?: boolean; + atomic?: boolean; + mode?: number; + allowedExtensions?: string[]; + checkFileExtension?: boolean; + maxSize?: number; + } = {} + ): { success: boolean; error?: string } { + const validation = this.validateFilePath(filePath) + if (!validation.isValid) { + return { success: false, error: validation.error } + } + + try { + const normalizedPath = validation.normalizedPath + + // 检查文件扩展名(如果启用) + if (options.checkFileExtension !== false) { + const ext = extname(normalizedPath).toLowerCase() + const allowedExts = options.allowedExtensions || + Array.from(this.allowedExtensions) + + if (allowedExts.length > 0 && !allowedExts.includes(ext)) { + return { + success: false, + error: `File extension '${ext}' is not allowed` + } + } + } + + // 检查内容大小 + const contentSize = typeof content === 'string' ? + Buffer.byteLength(content, options.encoding as BufferEncoding || 'utf8') : + content.length + + const maxSize = options.maxSize || this.maxFileSize + if (contentSize > maxSize) { + return { + success: false, + error: `Content too large (${contentSize} bytes, max ${maxSize} bytes)` + } + } + + // 创建目录(如果需要) + if (options.createDirectory) { + const dir = dirname(normalizedPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o755 }) + } + } + + // 原子写入(如果启用) + if (options.atomic) { + const tempPath = `${normalizedPath}.tmp.${Date.now()}` + + try { + // 写入临时文件 + writeFileSync(tempPath, content, { + encoding: options.encoding as BufferEncoding || 'utf8', + mode: options.mode || 0o644 + }) + + // 重命名为目标文件 + renameSync(tempPath, normalizedPath) + } catch (renameError) { + // 清理临时文件 + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath) + } + } catch { + // 忽略清理错误 + } + throw renameError + } + } else { + // 直接写入 + writeFileSync(normalizedPath, content, { + encoding: options.encoding as BufferEncoding || 'utf8', + mode: options.mode || 0o644 + }) + } + + return { success: true } + } catch (error) { + return { + success: false, + error: `Failed to write file: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 安全地删除文件 + * @param filePath 文件路径 + * @returns 删除结果 + */ + public safeDeleteFile(filePath: string): { success: boolean; error?: string } { + const validation = this.validateFilePath(filePath) + if (!validation.isValid) { + return { success: false, error: validation.error } + } + + try { + const normalizedPath = validation.normalizedPath + + // 检查文件是否存在 + if (!existsSync(normalizedPath)) { + return { success: false, error: 'File does not exist' } + } + + // 检查文件类型 + const stats = statSync(normalizedPath) + if (!stats.isFile()) { + return { success: false, error: 'Path is not a file' } + } + + // 检查写权限 + if ((stats.mode & parseInt('200', 8)) === 0) { + return { success: false, error: 'No write permission' } + } + + // 安全删除 + unlinkSync(normalizedPath) + return { success: true } + } catch (error) { + return { + success: false, + error: `Failed to delete file: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 安全地创建目录 + * @param dirPath 目录路径 + * @param mode 目录权限 + * @returns 创建结果 + */ + public safeCreateDirectory(dirPath: string, mode: number = 0o755): { success: boolean; error?: string } { + const validation = this.validateFilePath(dirPath) + if (!validation.isValid) { + return { success: false, error: validation.error } + } + + try { + const normalizedPath = validation.normalizedPath + + if (existsSync(normalizedPath)) { + const stats = statSync(normalizedPath) + if (!stats.isDirectory()) { + return { success: false, error: 'Path already exists and is not a directory' } + } + return { success: true } + } + + mkdirSync(normalizedPath, { recursive: true, mode }) + return { success: true } + } catch (error) { + return { + success: false, + error: `Failed to create directory: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 安全地获取文件信息 + * @param filePath 文件路径 + * @returns 文件信息 + */ + public safeGetFileInfo(filePath: string): { + success: boolean; + stats?: { + size: number; + isFile: boolean; + isDirectory: boolean; + mode: number; + atime: Date; + mtime: Date; + ctime: Date; + }; + error?: string + } { + const validation = this.validateFilePath(filePath) + if (!validation.isValid) { + return { success: false, error: validation.error } + } + + try { + const normalizedPath = validation.normalizedPath + + if (!existsSync(normalizedPath)) { + return { success: false, error: 'File does not exist' } + } + + const stats = statSync(normalizedPath) + + return { + success: true, + stats: { + size: stats.size, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + mode: stats.mode, + atime: stats.atime, + mtime: stats.mtime, + ctime: stats.ctime + } + } + } catch (error) { + return { + success: false, + error: `Failed to get file info: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 添加允许的基础路径 + * @param basePath 基础路径 + */ + public addAllowedBasePath(basePath: string): { success: boolean; error?: string } { + try { + const normalized = normalize(resolve(basePath)) + + // 验证路径是否存在 + if (!existsSync(normalized)) { + return { success: false, error: 'Base path does not exist' } + } + + this.allowedBasePaths.add(normalized) + return { success: true } + } catch (error) { + return { + success: false, + error: `Failed to add base path: ${error instanceof Error ? error.message : String(error)}` + } + } + } + + /** + * 设置最大文件大小 + * @param maxSize 最大文件大小(字节) + */ + public setMaxFileSize(maxSize: number): void { + this.maxFileSize = maxSize + } + + /** + * 添加允许的文件扩展名 + * @param extensions 文件扩展名数组 + */ + public addAllowedExtensions(extensions: string[]): void { + extensions.forEach(ext => { + if (!ext.startsWith('.')) { + ext = '.' + ext + } + this.allowedExtensions.add(ext.toLowerCase()) + }) + } + + /** + * 检查文件是否在允许的基础路径中 + * @param filePath 文件路径 + * @returns 是否允许 + */ + public isPathAllowed(filePath: string): boolean { + const validation = this.validateFilePath(filePath) + return validation.isValid + } + + /** + * 验证文件名安全性 + * @param filename 文件名 + * @returns 验证结果 + */ + public validateFileName(filename: string): { isValid: boolean; error?: string } { + // 检查文件名长度 + if (filename.length === 0) { + return { isValid: false, error: 'Filename cannot be empty' } + } + + if (filename.length > 255) { + return { isValid: false, error: 'Filename too long (max 255 characters)' } + } + + // 检查文件名字符 + const invalidChars = /[<>:"/\\|?*\x00-\x1F]/ + if (invalidChars.test(filename)) { + return { isValid: false, error: 'Filename contains invalid characters' } + } + + // 检查保留文件名 + const reservedNames = [ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + ] + + const baseName = filename.split('.')[0].toUpperCase() + if (reservedNames.includes(baseName)) { + return { isValid: false, error: 'Filename is reserved' } + } + + // 检查是否以点开头或结尾 + if (filename.startsWith('.') || filename.endsWith('.')) { + return { isValid: false, error: 'Filename cannot start or end with a dot' } + } + + // 检查是否以空格开头或结尾 + if (filename.startsWith(' ') || filename.endsWith(' ')) { + return { isValid: false, error: 'Filename cannot start or end with spaces' } + } + + return { isValid: true } + } +} + +// 导出单例实例 +export const secureFileService = SecureFileService.getInstance() \ No newline at end of file