Path traversal y Zip Slip en Node.js y Next.js: cómo frenar la lectura, escritura y extracción arbitraria de archivos [2026]
Path traversal es un bug de una línea con impacto a nivel de root
La mayoría de los bugs de manejo de archivos en un backend de Node.js son molestias. Path traversal no lo es. Una sola cadena controlada por el usuario concatenada dentro de una ruta del sistema de archivos convierte un endpoint de "descargar mi factura" en una lectura arbitraria de archivos — /etc/passwd, .env, ~/.aws/credentials, una clave privada SSH, tu base de datos SQLite — y la misma clase de bug en el lado de escritura convierte un endpoint de "renombrar mi archivo" en sobrescritura arbitraria de archivos, lo que es ejecución remota de código (RCE) en el momento en que un atacante puede alcanzar una ruta que se ejecuta o se carga.
Sobrevive a la revisión de código porque el código parece cuidadoso. Los equipos recurren a path.join, asumen que sanea ../, lo publican y siguen adelante.
Además, la superficie de ataque es más amplia de lo que la gente cree: Zip Slip — el primo del path traversal dentro de archivos comprimidos — es el mismo bug con otro sombrero. Toda startup que permite a los usuarios subir un ZIP, un backup .tar.gz, un paquete de exportación o un dataset y luego lo extrae en el servidor tiene un sink de path traversal. Sacar ../../../../etc/cron.d/backdoor de un archivo malicioso es escritura arbitraria de archivos, y está a un exec o a un intervalo de cron de comprometerlo todo.
Este artículo cubre el caso de lectura, el caso de escritura y el caso de archivos comprimidos, con el código exacto que cierra cada uno.
Por qué path.join no te salva
path.join normaliza los separadores y colapsa . y .. de forma léxica. No tiene ni idea de qué está "dentro del directorio" y qué no — simplemente produce una cadena. Todas estas son entradas de atacante que escapan de tu root previsto:
// Attacker-controlled value from a query param, body field, or multipart filename
path.join('/var/app/uploads', '../../../../etc/passwd');
// => '/etc/passwd' — escaped the root, no error was raised
path.join('/var/app/uploads', '..%2f..%2fetc/passwd');
// => '/var/app/uploads/..%2f..%2fetc/passwd' — still escapes if you decode after joining
Y el hermano más peligroso:
path.resolve('/var/app/uploads', '/etc/passwd');
// => '/etc/passwd' — an absolute segment resets the whole path
path.resolve es a lo que la gente recurre como "la solución", y ahí es exactamente donde vive el segundo bug: si la ruta del usuario es absoluta, resolve descarta el root por completo. En Windows lo mismo se aplica a rutas C:\..., \\server\share y \\?\, además de ..\..\ con barras invertidas y nombres cortos 8.3 como PROGRA~1.
Las variantes de codificación también importan, porque la decodificación ocurre en tu límite, no en el del sistema de archivos:
..%2f..%2f— separadores codificados en URL, decodificados por frameworks que decodifican después del enrutado.%252e%252e%252f— doble codificación, decodificada una vez por el proxy y otra por tu código.....//y..././— reconstruidos por reemplazos de cadena ingenuos que eliminan../solo una vez.- UTF-8 sobredimensionado (overlong,
%c0%ae) y caracteres de ancho completo, rechazados por elpathde Node pero a veces aceptados por otros runtimes o por la capa del sistema operativo.
Regla uno: decodifica exactamente una vez, en el borde, antes de validar — nunca después. Si tu framework ya decodificó el parámetro de ruta o el campo del body, esa es la única decodificación. No llames a decodeURIComponent otra vez más adelante, y nunca valides la forma cruda y decodifiques después.
El patrón correcto: resuelve y luego demuestra la contención
No valides la cadena. Valida el resultado resuelto contra el root, y hazlo sobre rutas reales:
import path from 'node:path';
import fs from 'node:fs/promises';
const ROOT = await fs.realpath(process.env.UPLOAD_DIR!);
export async function safeResolve(userPath: string): Promise<string> {
// 1. Lexical containment: resolve against the root, then prove it stayed inside.
const candidate = path.resolve(ROOT, userPath);
const rel = path.relative(ROOT, candidate);
if (rel === '') return candidate; // the root itself
if (rel.startsWith('..' + path.sep) || rel === '..' || path.isAbsolute(rel)) {
throw Object.assign(new Error('path traversal blocked'), { status: 400 });
}
// 2. Symlink defence: resolve the real path and re-check containment.
const real = await fs.realpath(candidate);
const relReal = path.relative(ROOT, real);
if (relReal.startsWith('..' + path.sep) || relReal === '..' || path.isAbsolute(relReal)) {
throw Object.assign(new Error('symlink escape blocked'), { status: 400 });
}
return real;
}
Tres detalles que son fáciles de pasar por alto:
path.relativees la primitiva correcta, nostartsWith(ROOT). La ruta/var/app/uploads-evilempieza por/var/app/uploadscomo cadena y no está dentro de él.path.relativedevuelve algo que empieza por..siempre que el destino está fuera del root.- Usa
path.resolve, nopath.join— quieres que el reinicio por ruta absoluta sea visible para que la comprobación de contención lo capture, en lugar de producir silenciosamente una ruta normalizada en la que confías. - La segunda comprobación con
realpathes lo que impide que un symlink plantado dentro del directorio de subidas (ln -s /etc/passwd pwn) convierta tu propio root en una salida. Resuelve también el root conrealpathuna vez al arrancar, o un root enlazado por symlink dejará la comparación sin efecto.
La corrección que deberías desplegar primero: deja de confiar en los nombres de archivo del usuario
Todo lo anterior es defensa en profundidad. La corrección estructural es no dejar que una cadena suministrada por el usuario llegue nunca a path:
- Al subir, genera un ID aleatorio (
crypto.randomUUID()), úsalo como nombre de archivo en disco y guarda el nombre original (solo para mostrar) en la base de datos junto a la fila de propiedad del usuario. - Sirve archivos por ID:
GET /api/files/:idcarga la fila, autoriza contra la sesión (file.ownerId === session.userId), construye la ruta a partir del ID almacenado más una extensión fija, y lo transmite en streaming. - Nunca aceptes una ruta, un nombre de archivo ni una extensión del cliente en el lado de lectura. El cliente envía un ID opaco, no un nombre.
Eso elimina la clase de vulnerabilidad en lugar de filtrarla. Consulta nuestra guía de subida segura de archivos para el lado de subida (validación por magic bytes, allowlists de extensiones, almacenamiento fuera del web root) y la guía de IDOR para la autorización a nivel de objeto que debe acompañar a cada una de estas consultas.
Zip Slip: el mismo bug dentro de cada archivo comprimido
Zip Slip es path traversal a través de las entradas de un archivo comprimido. El formato permite que cada entrada declare su propio nombre, y un archivo malicioso declara ../../../../etc/cron.d/backdoor — o una ruta absoluta, o una ruta de unidad de Windows, o una ruta que escribe a través de un symlink creado por una entrada anterior. Se divulgó en todo el ecosistema en 2018 y desde entonces se ha parcheado, vuelto a romper y vuelto a parchear en librerías concretas. Solo node-tar necesitó una serie de correcciones en 2021 por escritura arbitraria de archivos vía symlink (CVE-2021-32803), creación arbitraria de archivos vía rutas absolutas (CVE-2021-32804) y escapes de rutas relativas de unidad de Windows (CVE-2021-37701, CVE-2021-37712, CVE-2021-37713); problemas comparables aparecieron en adm-zip, unzip-stream, decompress y extract-zip.
La lección no es "actualiza la librería" — es que tu código de extracción debe imponer la contención él mismo, entrada por entrada, porque no puedes saber qué se equivocará la próxima librería de compresión.
Forma vulnerable — un sink directo:
import AdmZip from 'adm-zip';
const zip = new AdmZip(buffer);
zip.extractAllTo('/var/app/extracted', /* overwrite */ true);
// Every entry name is attacker-controlled. '../../../../' escapes the root.
Forma segura — valida cada entrada antes de que toque el disco:
import path from 'node:path';
import fs from 'node:fs/promises';
import tar from 'tar';
const DEST = await fs.realpath('/var/app/extracted');
function entryTarget(entryName: string): string {
if (path.isAbsolute(entryName)) throw new Error(`zip slip blocked: ${entryName}`);
const target = path.resolve(DEST, entryName);
const rel = path.relative(DEST, target);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`zip slip blocked: ${entryName}`);
}
return target;
}
await tar.x({
file: archivePath,
cwd: DEST,
// Refuse links and device entries outright — they are the second-stage attack.
filter: (entryPath, entry) => {
if (entry.type === 'SymbolicLink' || entry.type === 'Link') return false;
entryTarget(entryPath);
return true;
},
});
Puntos que conviene interiorizar:
- Valida el nombre de la entrada y el tipo de entrada. Los symlinks y los hardlinks (
entry.type === 'SymbolicLink' | 'Link') son la forma en que funciona la segunda etapa: extrae un symlink que apunta a/etc, luego extrae un archivo normal "dentro" de él y ya has escrito en/etc. Nunca extraigas enlaces de archivos no confiables; si de verdad tienes que hacerlo, resuelve el destino del enlace y comprueba la contención también de ese destino. - Rechaza entradas absolutas y relativas de unidad — un
/inicial, unC:\o un\\?\es un rechazo inmediato, no un problema de normalización que haya que resolver. - Extrae en un directorio nuevo, vacío y efímero creado con
fs.mkdtemp(fuera del web root) y luego mueve los archivos validados a su sitio. Un destino de extracción nunca debería ser una ruta que un atacante pueda nombrar de antemano. - Limita el archivo comprimido: número de entradas, tamaño total descomprimido y tamaño por entrada, comprobando los tamaños declarados y imponiendo los límites durante el streaming (el caso de la zip bomb). Nuestra guía de rate limiting de API cubre el presupuesto a nivel de petición; el archivo comprimido necesita el suyo propio.
- Nunca extraigas dentro de un directorio que se sirve. Si la extracción cae bajo
public/o cualquier ruta que el servidor web mapee, una sola entrada permitida con extensión.js,.htmlo.phpse convierte en XSS almacenado o algo peor.
La condición de carrera de comprobación-uso (TOCTOU)
safeResolve valida una ruta, y luego la abres. Entre la comprobación y la apertura, un atacante puede cambiar un componente de la ruta por un symlink — una condición de carrera de comprobación-uso (TOCTOU, time-of-check to time-of-use). En Linux la mitigación estándar es abrir con O_NOFOLLOW y operar sobre el descriptor de archivo devuelto, de modo que el archivo que validaste es el archivo que usas:
import { constants } from 'node:fs';
import fs from 'node:fs/promises';
// Fails with ELOOP if the final path component is a symlink.
const fh = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
const stream = fh.createReadStream();
Para escrituras, abre con O_CREAT | O_EXCL (flag: 'wx') para que nunca puedas sobrescribir silenciosamente un archivo existente ni seguir un symlink existente. Mantén la extracción y cualquier procesamiento posterior en el mismo directorio efímero para que un atacante no tenga ventana para plantar un symlink en una ubicación compartida.
Instrumenta la detección
Cada intento de traversal que bloqueas es un atacante real, no un error tipográfico. Registra el intento como evento de seguridad — ID de usuario, sesión, ruta saneada, request ID — y alerta ante ráfagas, porque un escaneo buscando ../../ por tus endpoints es exactamente el reconocimiento que precede a un golpe mayor. Sanea la ruta registrada (elimina saltos de línea y caracteres de control): un atacante que inyecta \n en un nombre de archivo registrado puede falsificar entradas de log (log injection) y engañar a quien las lea después.
La checklist para la startup
- [ ] Ninguna cadena suministrada por el usuario llega a
fsopathsin validar; el lado de lectura direcciona archivos por ID opaco con una comprobación de autorización respaldada por base de datos. - [ ] Cuando una ruta debe venir del cliente, se valida con contención mediante
path.resolve+path.relativey luego se revalida conrealpath. - [ ] El propio root de almacenamiento se resuelve con
realpathal arrancar, y vive fuera del web root. - [ ] La entrada se decodifica exactamente una vez, en el límite, antes de validar — nunca validada en crudo y decodificada después.
- [ ] La extracción de archivos comprimidos valida el nombre de cada entrada con la comprobación de contención y rechaza entradas
SymbolicLink/Linky rutas absolutas o relativas de unidad. - [ ] Los archivos comprimidos se extraen en un directorio
mkdtempnuevo con límites de número de entradas, tamaño total y tamaño por entrada impuestos durante el streaming. - [ ] Las aperturas de archivo usan
O_NOFOLLOW; las escrituras usanO_CREAT | O_EXCL; no hay hueco de comprobación-uso en rutas influidas por el atacante. - [ ] Los intentos de traversal bloqueados se registran como eventos de seguridad con la ruta saneada, y las alertas se disparan ante ráfagas.
- [ ] El contenido subido nunca se extrae ni se almacena dentro de un directorio que sirve el servidor web.
- [ ] Las librerías de archivos comprimidos y zip están fijadas y seguidas por Dependabot — pero la contención la impone tu código, no se delega en la librería.
Qué hacer esta semana
El grep es tu aliado: busca en el código cada llamada a fs.readFile, fs.writeFile, createReadStream, sendFile, res.download, extractAllTo y tar.x/tar.extract, y luego traza cada una hacia atrás hasta donde viene su argumento de ruta. Cualquier ruta que se origine en un query param, un body JSON, un nombre de archivo de un multipart o una entrada de archivo comprimido es un hallazgo. Corrígelos en este orden: primero IDs opacos y autorización respaldada por base de datos (eso mata los casos de lectura y escritura), segundo la validación por entrada en el código de extracción, tercero el endurecimiento de TOCTOU y la telemetría. El path traversal es un bug de una línea, pero no es una corrección de una línea — la comprobación de contención, no el filtro de cadenas, es lo que aguanta.
¿Quieres una revisión profesional de tu manejo de archivos y procesamiento de comprimidos? Agenda una auditoría de seguridad — probamos cada ruta de archivo, flujo de subida y endpoint de extracción como lo haría un atacante.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.