gongxiangp2/lib/path-security.ts

354 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'),
// 添加其他允许的目录
]
}