Initial commit
This commit is contained in:
106
api/utils/pathSafety.ts
Normal file
106
api/utils/pathSafety.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user