354 lines
9.0 KiB
TypeScript
354 lines
9.0 KiB
TypeScript
/**
|
||
* 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<string | null> {
|
||
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<boolean> {
|
||
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'),
|
||
// 添加其他允许的目录
|
||
]
|
||
}
|