Files
XCDesktop/api/utils/pathSafety.ts

107 lines
2.9 KiB
TypeScript
Raw Normal View History

2026-03-08 01:34:54 +08:00
import path from 'path'
import fs from 'fs/promises'
import { NOTEBOOK_ROOT } from '../config/paths.js'
import { AccessDeniedError } from '../../shared/errors/index.js'
const DANGEROUS_PATTERNS = [
/\.\./,
/\0/,
/%2e%2e[%/]/i,
/%252e%252e[%/]/i,
/\.\.%2f/i,
/\.\.%5c/i,
/%c0%ae/i,
/%c1%9c/i,
/%c0%ae%c0%ae/i,
/%c1%9c%c1%9c/i,
/\.\.%c0%af/i,
/\.\.%c1%9c/i,
/%252e/i,
/%uff0e/i,
/%u002e/i,
]
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)$/i
const DOUBLE_ENCODE_PATTERNS = [
/%25[0-9a-fA-F]{2}/,
/%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/,
]
export const normalizeRelPath = (input: string) => {
const trimmed = input.replace(/\0/g, '').trim()
return trimmed.replace(/^[/\\]+/, '')
}
export const containsPathTraversal = (input: string): boolean => {
const decoded = decodeURIComponentSafe(input)
return DANGEROUS_PATTERNS.some(pattern => pattern.test(input) || pattern.test(decoded))
}
export const containsDoubleEncoding = (input: string): boolean => {
return DOUBLE_ENCODE_PATTERNS.some(pattern => pattern.test(input))
}
export const hasPathSecurityIssues = (input: string): boolean => {
return containsPathTraversal(input) || containsDoubleEncoding(input)
}
const decodeURIComponentSafe = (input: string): string => {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
export const resolveNotebookPath = (relPath: string) => {
if (hasPathSecurityIssues(relPath)) {
throw new AccessDeniedError('Path traversal detected')
}
const safeRelPath = normalizeRelPath(relPath)
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
const fullPath = path.resolve(notebookRoot, safeRelPath)
if (!fullPath.startsWith(notebookRoot)) {
throw new AccessDeniedError('Access denied')
}
return { safeRelPath, fullPath }
}
export const resolveNotebookPathSafe = async (relPath: string) => {
if (hasPathSecurityIssues(relPath)) {
throw new AccessDeniedError('Path traversal detected')
}
const safeRelPath = normalizeRelPath(relPath)
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
const fullPath = path.resolve(notebookRoot, safeRelPath)
try {
await fs.access(fullPath)
} catch {
return { safeRelPath, fullPath, realPath: null }
}
const realFullPath = await fs.realpath(fullPath)
const realRoot = await fs.realpath(notebookRoot)
if (!realFullPath.startsWith(realRoot)) {
throw new AccessDeniedError('Symbolic link escapes notebook root')
}
return { safeRelPath, fullPath, realPath: realFullPath }
}
export const validateFileName = (name: string): boolean => {
if (!name || name.length === 0) return false
if (name.length > 255) return false
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/
if (invalidChars.test(name)) return false
if (WINDOWS_RESERVED_NAMES.test(name)) return false
if (name.startsWith('.') || name.endsWith('.')) return false
return true
}