107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
|
|
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
|
||
|
|
}
|