
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.
Paso 3: conviértete en cualquiera. El middleware de autenticación vuelve a resolver al usuario desde la base de datos usando la reclamación id. Deliberadamente no confía en una reclamación como isSuperAdmin del token — buen diseño — pero ese endurecimiento es irrelevante una vez que puedes firmar un id arbitrario. Firmar { id: <id de usuario víctima> } produce una sesión indistinguible de un inicio de sesión legítimo:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
Validé esto contra la lógica de verificación real del proyecto con la dependencia real jsonwebtoken: un token firmado con el secreto recuperado fue aceptado, y el mismo token firmado con un secreto incorrecto fue rechazado. El caso de control importa — sin él tienes una suposición, no un hallazgo.
Paso 4: el camino paralelo. DATABASE_URL por sí solo es suficiente para el acceso directo a Postgres: lee todas las cuentas conectadas o cambia directamente un indicador de administrador.
Sin contraseña. Sin acceso previo. Sin interacción del usuario. Una única petición HTTP no autenticada.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N y UI:N son las dos métricas que hacen el trabajo. La ruta no requiere sesión ni interacción de la víctima: el atacante actúa solo, a través de la red, contra una configuración por defecto.
El único limitador honesto es la puerta de configuración: las implementaciones en S3 o R2 no están expuestas, porque la ruta se reescribe a /404. Eso reduce la población afectada pero no la severidad para cualquiera que esté dentro — y local es el valor por defecto incluido.
El parche de los mantenedores (7936062) tiene ocho líneas y merece la pena leerlo, porque es correcto de una manera en que estas correcciones a menudo no lo son:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
Dos cosas que hace bien:
resolve() colapsa .. después de que la decodificación esté completa, así que no importa cómo se haya colado el traversal a través del enrutado. La comprobación ahora se sitúa donde está el peligro.base + sep, no contra base. Un filePath.startsWith(base) ingenuo aceptaría /app/uploads-evil/x como si estuviera dentro de /app/uploads — un bypass clásico de coincidencia de prefijo. Añadir el separador lo cierra, y la cláusula filePath !== base mantiene el propio directorio como válido.Esa es la forma correcta para una comprobación de contención: resolver y luego comparar contra la base con un separador final.
Todos los tiempos en UTC, 2026-07-20 salvo que se indique.
| Hora | Evento |
|---|---|
| 05:55 | Aviso reportado al equipo de Postiz |
| 07:44 | Reconocido y verificado por los mantenedores |
| 12:18 | Corrección commiteada, verificada y publicada |
| 2026-08-07 14:13 | CVE-2026-19264 asignado por Postiz (CNA) |
| 2026-08-07 14:15 | Aviso de seguridad de GitHub publicado |
Seis horas y veintitrés minutos desde el reporte hasta el parche publicado, en un proyecto open source sin programa de recompensas asociado. He tenido reportes sin tocar durante meses en organizaciones con equipos de seguridad dedicados. Crédito a Enno Gelhaus por la coordinación y a Nevo David por la remediación.
Que un control supere tu prueba no significa que el control esté en el lugar correcto. El 404 era real. Next.js realmente colapsa ../. La defensa simplemente se ejecutaba antes de que la entrada terminara de decodificarse, lo que significaba que estaba protegiendo al enrutador en lugar de a la llamada al sistema de archivos. Cuando encuentres una mitigación, pregunta cuándo se ejecuta en relación con el sink, no solo si existe.
La codificación es una capa, y las capas se pelan a ritmos diferentes. Cada vez que dos componentes de un pipeline de peticiones no se ponen de acuerdo sobre cuántas veces decodificar, la brecha entre ellos es explotable. Los emparejadores de rutas, los middlewares y los manejadores a menudo no se ponen de acuerdo.
Clasifica una primitiva de lectura de archivos por lo que el proceso puede alcanzar, no por la primitiva. «Lectura arbitraria de archivos» suena a divulgación de información. Se convirtió en Crítica porque el entorno era legible, el secreto que contenía firmaba sesiones y esas sesiones nunca expiraban. Sigue la cadena antes de puntuarla.
Los tokens que no expiran convierten una filtración en un compromiso permanente. La divulgación de una clave de firma con tokens de corta duración es un mal día. Sin expiresIn, es irrecuperable sin rotar el secreto, y la mayoría de los operadores nunca sabrán que necesitaban hacerlo.
Ejecuta el caso de control. Verificar que un token firmado con el secreto incorrecto sea rechazado es lo que separa un hallazgo demostrado de uno asumido.
Si autoalojas Postiz:
JWT_SECRET está comprometido si ejecutaste una versión afectada en un host alcanzable públicamente con STORAGE_PROVIDER=local. Rótalo. Como los tokens no llevan expiración, la rotación es la única forma de invalidar cualquiera que haya sido forjado.DATABASE_URL y cualquier secreto OAuth de proveedores conectados almacenados en el mismo entorno.GET a /uploads/ que contengan %2e o %2f.Investigación realizada de forma independiente y divulgada al proveedor bajo divulgación coordinada. Publicada después de que la corrección se distribuyera y el aviso se hiciera público. No se accedió a sistemas de terceros: toda la validación se realizó contra una instancia local construida a partir del propio código fuente del proyecto.
Este informe está licenciado bajo CC BY 4.0: comparte y adapta libremente con atribución. Los extractos de código de gitroomhq/postiz-app se citan para análisis de seguridad y permanecen bajo la licencia de ese proyecto.
Krithik Babu P - @DarkLycn1976