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 }