/** * R29修复:路径安全工具 * * 提供路径遍历攻击防护功能 * * 常见攻击方式: * - ../../../etc/passwd * - ..%2F..%2F..%2Fetc%2Fpasswd (URL编码) * - ....//....//etc/passwd (双斜杠绕过) */ import path from 'path' import { lstat, mkdir } from 'fs/promises' export const MAX_SAFE_FILE_NAME_BYTES = 255 function decodePathForValidation(value: string): string { let decoded = value for (let index = 0; index < 5; index += 1) { try { const next = decodeURIComponent(decoded) if (next === decoded) { break } decoded = next } catch { break } } return decoded } /** * 验证客户端文件名是单一叶子文件名,而不是路径。 * 返回 trim + NFC 后的文件名;不安全时返回 null。 */ export function normalizeSafeFileName( fileName: string, maxBytes: number = MAX_SAFE_FILE_NAME_BYTES, ): string | null { if (typeof fileName !== 'string') { return null } const normalized = fileName.trim().normalize('NFC') if ( !normalized || normalized === '.' || normalized === '..' || Buffer.byteLength(normalized, 'utf8') > maxBytes ) { return null } const decoded = decodePathForValidation(normalized) const candidates = decoded === normalized ? [normalized] : [normalized, decoded] for (const candidate of candidates) { if ( path.posix.isAbsolute(candidate) || path.win32.isAbsolute(candidate) || /^[A-Za-z]:/.test(candidate) || candidate.startsWith('\\\\') || candidate.includes('/') || candidate.includes('\\') || candidate.includes('..') || /[\x00-\x1f\x7f]/.test(candidate) || /[<>:"|?*]/.test(candidate) ) { return null } } return normalized } /** * 将路径解析为 basePath 的严格子项。不能返回 basePath 本身。 */ export function resolveStrictChildPath( basePath: string, ...segments: string[] ): string | null { if ( !basePath || segments.length === 0 || segments.some((segment) => ( !segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\') || path.posix.isAbsolute(segment) || path.win32.isAbsolute(segment) || /^[A-Za-z]:/.test(segment) )) ) { return null } const normalizedBase = path.resolve(basePath) const targetPath = path.resolve(normalizedBase, ...segments) const relativePath = path.relative(normalizedBase, targetPath) if ( !relativePath || relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath) ) { return null } return targetPath } /** * 在可信根目录下逐级创建目录,并拒绝任何已存在的符号链接或非目录节点。 */ export async function ensureSafeDirectoryWithinRoot( basePath: string, ...segments: string[] ): Promise { const targetPath = resolveStrictChildPath(basePath, ...segments) if (!targetPath) { return null } const normalizedBase = path.resolve(basePath) await mkdir(normalizedBase, { recursive: true }) const baseStats = await lstat(normalizedBase) if (baseStats.isSymbolicLink() || !baseStats.isDirectory()) { return null } let currentPath = normalizedBase for (const segment of segments) { const nextPath = resolveStrictChildPath(currentPath, segment) if (!nextPath || path.relative(normalizedBase, nextPath).startsWith(`..${path.sep}`)) { return null } try { const stats = await lstat(nextPath) if (stats.isSymbolicLink() || !stats.isDirectory()) { return null } } catch (error: any) { if (error?.code !== 'ENOENT') { throw error } await mkdir(nextPath) const createdStats = await lstat(nextPath) if (createdStats.isSymbolicLink() || !createdStats.isDirectory()) { return null } } currentPath = nextPath } return currentPath === targetPath ? currentPath : null } /** * 检查 basePath 到 targetPath 的所有现存节点是否都不是符号链接。 */ export async function isPathFreeOfSymbolicLinks( basePath: string, targetPath: string, options?: { allowMissingLeaf?: boolean allowMissingDescendants?: boolean }, ): Promise { const normalizedBase = path.resolve(basePath) const normalizedTarget = path.resolve(targetPath) const relativePath = path.relative(normalizedBase, normalizedTarget) if ( !relativePath || relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath) ) { return false } const baseStats = await lstat(normalizedBase).catch(() => null) if (!baseStats || baseStats.isSymbolicLink() || !baseStats.isDirectory()) { return false } const parts = relativePath.split(path.sep).filter(Boolean) let currentPath = normalizedBase for (let index = 0; index < parts.length; index += 1) { currentPath = path.join(currentPath, parts[index]) const stats = await lstat(currentPath).catch((error: any) => { if (error?.code === 'ENOENT') { return null } throw error }) if (!stats) { return ( options?.allowMissingDescendants === true || (options?.allowMissingLeaf === true && index === parts.length - 1) ) } if (stats.isSymbolicLink()) { return false } } return true } /** * 检查路径是否包含路径遍历字符 * @param inputPath 待检查的路径 * @returns 是否安全 */ export function isPathSafe(inputPath: string): boolean { if (!inputPath) return false // 解码 URL 编码 let decodedPath = inputPath try { decodedPath = decodeURIComponent(inputPath) } catch { // 解码失败,使用原始路径 } // 检查常见的路径遍历模式 const dangerousPatterns = [ /\.\./, // .. /\.\.%2f/i, // URL编码的 ../ /%2e%2e/i, // 双点的URL编码 /\.\.\\/, // Windows 路径遍历 /\0/, // null字节注入 ] for (const pattern of dangerousPatterns) { if (pattern.test(decodedPath)) { console.warn(`[PathSecurity] 检测到危险路径模式: ${inputPath}`) return false } } return true } /** * 规范化并验证路径在允许的基目录内 * @param basePath 基础目录(必须是绝对路径) * @param userPath 用户提供的路径 * @returns 安全的绝对路径,如果不安全返回 null */ export function resolveSafePath(basePath: string, userPath: string): string | null { if (!basePath || !userPath) { return null } // 确保基目录是绝对路径 if (!path.isAbsolute(basePath)) { console.error('[PathSecurity] 基目录必须是绝对路径:', basePath) return null } // 先检查是否包含危险模式 if (!isPathSafe(userPath)) { return null } // 规范化路径 const normalizedBase = path.normalize(basePath) const fullPath = path.normalize(path.join(normalizedBase, userPath)) // 确保结果路径在基目录内 // 使用 startsWith 检查,但要注意边界情况 // 例如:/uploads 不应该匹配 /uploads-backup const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep if (fullPath === normalizedBase || fullPath.startsWith(baseWithSep)) { return fullPath } console.warn(`[PathSecurity] 路径越界: ${fullPath} 不在 ${normalizedBase} 内`) return null } /** * 验证文件路径是否在允许的目录列表中 * @param filePath 文件的绝对路径 * @param allowedDirs 允许的目录列表 * @returns 是否在允许的目录中 */ export function isPathInAllowedDirs(filePath: string, allowedDirs: string[]): boolean { if (!filePath || !allowedDirs || allowedDirs.length === 0) { return false } const normalizedPath = path.normalize(filePath) for (const dir of allowedDirs) { const normalizedDir = path.normalize(dir) const dirWithSep = normalizedDir.endsWith(path.sep) ? normalizedDir : normalizedDir + path.sep if (normalizedPath === normalizedDir || normalizedPath.startsWith(dirWithSep)) { return true } } return false } /** * 清理文件名,移除危险字符 * @param fileName 原始文件名 * @returns 安全的文件名 */ export function sanitizeFileName(fileName: string): string { if (!fileName) return 'unnamed' // 移除路径分隔符和其他危险字符 return fileName .replace(/[\/\\]/g, '_') // 替换路径分隔符 .replace(/\.\./g, '_') // 替换 .. .replace(/[\x00-\x1f]/g, '') // 移除控制字符 .replace(/[<>:"|?*]/g, '_') // 移除 Windows 不允许的字符 .trim() || 'unnamed' } /** * 获取允许的上传/下载目录 * 基于项目根目录 */ export function getAllowedFileDirectories(): string[] { const cwd = process.cwd() return [ path.join(cwd, 'uploads'), path.join(cwd, 'temp'), path.join(cwd, 'public'), // 添加其他允许的目录 ] }