diff --git a/packages/rdx-utils/src/helpers/index.ts b/packages/rdx-utils/src/helpers/index.ts index 4a86e3b7..d390b0bb 100644 --- a/packages/rdx-utils/src/helpers/index.ts +++ b/packages/rdx-utils/src/helpers/index.ts @@ -5,5 +5,6 @@ export * from "./patch-field"; export * from "./result"; export * from "./result-collection"; export * from "./rule-validator"; +export * from "./safe-path"; export * from "./types"; export * from "./utils"; diff --git a/packages/rdx-utils/src/helpers/safe-path.ts b/packages/rdx-utils/src/helpers/safe-path.ts new file mode 100644 index 00000000..ffbef1ff --- /dev/null +++ b/packages/rdx-utils/src/helpers/safe-path.ts @@ -0,0 +1,79 @@ +import path from "node:path"; + +/** + * Construye rutas de filesystem POSIX de forma segura. + * + * Garantías: + * - Previene path traversal + * - Elimina caracteres no seguros + * - Siempre resuelve dentro de basePath + * - Fail-fast si no es posible construir una ruta válida + * + * Pensado para Unix/Linux. + */ + +export interface SafePathOptions { + basePath: string; + segments: string[]; + filename?: string; +} + +export function buildSafePath(options: SafePathOptions): string { + const { basePath, segments, filename } = options; + + if (!basePath) { + throw new Error("basePath is required"); + } + + const safeSegments = segments.map(sanitizeSegment); + + const relativePath = filename + ? path.posix.join(...safeSegments, sanitizeFilename(filename)) + : path.posix.join(...safeSegments); + + const absolutePath = path.resolve(basePath, relativePath); + const normalizedBasePath = path.resolve(basePath); + + // 🔐 Defensa CRÍTICA contra path traversal + if (!absolutePath.startsWith(normalizedBasePath + path.sep)) { + throw new Error(`Resolved path escapes basePath: ${absolutePath}`); + } + + return absolutePath; +} + +/** + * Sanitiza un segmento de path (directorio). + * Whitelist estricta. + */ +function sanitizeSegment(value: string): string { + const sanitized = value + .normalize("NFKD") + .replace(/[^a-zA-Z0-9-_]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(); + + if (!sanitized) { + throw new Error(`Invalid path segment: "${value}"`); + } + + return sanitized; +} + +/** + * Sanitiza un nombre de fichero. + * Mantiene la extensión. + */ +function sanitizeFilename(filename: string): string { + const ext = path.extname(filename); + const name = path.basename(filename, ext); + + const safeName = sanitizeSegment(name); + + if (!ext || ext.length > 10) { + throw new Error(`Invalid or missing file extension in "${filename}"`); + } + + return `${safeName}${ext.toLowerCase()}`; +}