
CVE-2026-19264 - Traversal de ruta crítico sin autenticación que permite la toma total de la instancia en Postiz (< 2.22.1). Informe técnico: bypass del orden de decodificación, escalada de JWT_SECRET y análisis de la corrección upstream.

Autor: Krithik Babu P (@DarkLycn1976)
Publicado: 2026-08-10
CVE: CVE-2026-19264
Severidad: Crítica - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - Limitación inapropiada de un nombre de ruta a un directorio restringido
Afectados: gitroomhq/postiz-app < 2.22.1
Corregido en: v2.22.1
Postiz servía los medios almacenados localmente a través de una ruta que unía los segmentos de ruta proporcionados en la URL al directorio de subida y transmitía el resultado de vuelta al cliente, sin normalización de ruta, sin comprobación de contención y sin autenticación.
La carga útil de traversal obvia devuelve 404, porque Next.js colapsa los segmentos ../ antes del enrutado. Pero los separadores codificados en URL sobreviven a la coincidencia de rutas y se decodifican exactamente una vez más de camino a la llamada al sistema de archivos, restaurando el traversal al otro lado de todas las comprobaciones.
Un atacante no autenticado podía leer cualquier archivo legible por el proceso de la aplicación, incluido su propio entorno, que contiene el secreto de firma de JWT. Debido a que Postiz firma los tokens de sesión con ese secreto y los emite sin una reclamación de expiración, recuperarlo convierte una primitiva de lectura de archivos en una sesión permanente y falsificable como cualquier usuario, incluido un administrador.
Una única petición GET no autenticada hasta la toma de control total de la instancia.
Postiz es una plataforma open source de programación de publicaciones en redes sociales — unas 34.000 estrellas en GitHub en el momento de escribir esto — construida como un frontend de Next.js con un backend de NestJS. Es ampliamente autoalojada por agencias y pequeños equipos para gestionar cuentas de redes sociales conectadas, contenido programado y facturación.
Las implementaciones autoalojadas pueden almacenar los medios subidos localmente en lugar de en un almacenamiento de objetos. Ese comportamiento está controlado por una única variable de entorno:
STORAGE_PROVIDER=local
Este es el valor incluido en .env.example, así que es lo que ejecuta la mayoría de los autoalojadores a menos que configuren deliberadamente S3 o Cloudflare R2.
Cuando el almacenamiento local está activo, next.config.js reescribe la ruta pública /uploads/:path* hacia una ruta de API interna:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
Dos propiedades hacen que esta ruta sea interesante antes de que intervenga siquiera un fallo:
[[...path]] significa que cada componente restante de la ruta llega como un array que el manejador es libre de interpretar.Cuando STORAGE_PROVIDER es cualquier valor distinto de local, la reescritura apunta a /404 y el manejador es inalcanzable. Esa puerta de configuración es lo único que se interpone entre una implementación y este fallo.
El manejador, antes de v2.22.1:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
Tres defectos en cuatro líneas:
path.normalize() ni a path.resolve(). Los segmentos que llegan se concatenan literalmente.filePath resultante siga viviendo dentro de UPLOAD_DIRECTORY.+ '/' + trata los componentes como texto, no como una ruta con semántica.El resultado va directamente a createReadStream() y los bytes se transmiten al llamante con un tipo MIME inferido del nombre de archivo. No hay lista blanca de extensiones ni filtro de contenido.
El ataque de manual es:
GET /uploads/../../../etc/passwd
En Postiz esto devuelve 404, y ese 404 es la razón completa por la que este fallo sobrevivió hasta ser encontrado.
Next.js normaliza la ruta de la petición durante el enrutado. Los segmentos ../ sin procesar se colapsan antes de que el enrutador decida qué manejador invocar. Para cuando la petición llega al catch-all, el traversal ya ha sido eliminado: o la ruta resuelve a algún sitio sin una ruta coincidente, o resuelve de vuelta dentro de /uploads con los segmentos de punto eliminados.
Para alguien que prueba rápido, ese 404 se lee como "el framework se encarga de esto". Es una defensa genuina y funcional. El problema no es que esté ausente: es dónde en el pipeline se ejecuta.
La coincidencia de rutas y el manejador de peticiones no realizan el mismo número de pasadas de decodificación de porcentajes.
Si los separadores están codificados en porcentaje, la secuencia no es un separador de ruta durante la coincidencia de rutas. %2e%2e%2f es solo una cadena opaca: texto inerte que el normalizador no tiene razón para tocar. Atraviesa el enrutado intacta, el catch-all la empareja y se decodifica de camino a los params del manejador, donde vuelve a convertirse en ../.
En ese punto se concatena a UPLOAD_DIRECTORY y se entrega a createReadStream(): pasado el enrutado, pasado la normalización, pasado todos los controles que la habrían detenido.
Formas que funcionan:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, returned the real /etc/passwd
La doble codificación no funciona: %252e permanece literal a través de la única pasada de decodificación y nunca se convierte en un punto. Exactamente una capa de codificación es el punto óptimo, un recordatorio útil de que «codificar más fuerte» no es una estrategia.
El invariante que debes retener:
Un control que se ejecuta antes de que la decodificación esté completa no está protegiendo el sink.
Una primitiva de lectura de archivos es Alta por sí sola. Lo que la convierte en Crítica es lo que alcanza.
Paso 1: lee el entorno. La propia configuración del proceso de Node está en disco en la raíz de la implementación. .env produce, entre otras cosas:
JWT_SECRET - la clave de firma de los tokens de sesiónDATABASE_URL - credenciales completas de PostgresPaso 2: forja una sesión. Postiz firma los tokens de sesión con JWT_SECRET usando HS256 mediante jsonwebtoken. De forma crítica, los tokens se emiten sin expiresIn, por lo que un token forjado es válido indefinidamente.