
CVE-2026-19264 - Traversée de chemin non authentifiée critique menant à une prise de contrôle complète de l'instance dans Postiz (< 2.22.1). Analyse technique : contournement de l'ordre de décodage, escalade JWT_SECRET, et analyse du correctif en amont.

Auteur : Krithik Babu P (@DarkLycn1976)
Publié : 2026-08-10
CVE : CVE-2026-19264
Sévérité : Critical — CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE : CWE-22 — Limitation inappropriée d'un nom de chemin à un répertoire restreint
Versions affectées : gitroomhq/postiz-app < 2.22.1
Corrigé dans : v2.22.1
Postiz servait les médias stockés localement via une route qui concaténait les segments de chemin fournis dans l'URL au répertoire de téléversement, puis renvoyait le résultat en streaming — sans normalisation de chemin, sans vérification de confinement et sans authentification.
La charge utile de traversée évidente renvoie une 404, car Next.js réduit les segments ../ avant le routage. Mais les séparateurs encodés en URL survivent à la correspondance de routes et sont décodés exactement une fois de plus sur le chemin vers l'appel au système de fichiers, rétablissant la traversée de l'autre côté de chaque contrôle.
Un attaquant non authentifié pouvait lire tout fichier lisible par le processus de l'application — y compris son propre environnement, qui contient le secret de signature JWT. Comme Postiz signe les jetons de session avec ce secret et les émet sans date d'expiration, récupérer ce secret transforme une primitive de lecture de fichier en une session permanente et forgeable sous l'identité de n'importe quel utilisateur, y compris un administrateur.
Une seule requête GET non authentifiée pour une prise de contrôle totale de l'instance.
Postiz est une plateforme open-source de planification de publications sur les réseaux sociaux — environ 34 000 étoiles GitHub au moment de la rédaction — construite avec un frontend Next.js et un backend NestJS. Elle est largement auto-hébergée par des agences et de petites équipes pour gérer des comptes de réseaux sociaux connectés, le contenu planifié et la facturation.
Les déploiements auto-hébergés peuvent stocker les médias téléversés localement plutôt que sur un stockage d'objets. Ce comportement est contrôlé par une seule variable d'environnement :
STORAGE_PROVIDER=local
C'est la valeur fournie dans .env.example, c'est donc ce que la plupart des auto-hébergeurs utilisent, sauf s'ils configurent délibérément S3 ou Cloudflare R2.
Lorsque le stockage local est actif, next.config.js réécrit le chemin public /uploads/:path* vers une route API interne :
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
Deux propriétés rendent cette route intéressante avant même qu'un bug n'intervienne :
[[...path]] signifie que chaque composant de chemin restant arrive sous forme de tableau que le handler est libre d'interpréter.Lorsque STORAGE_PROVIDER a une valeur autre que local, la réécriture pointe vers /404 et le handler est inaccessible. Cette barrière de configuration est la seule chose qui se dresse entre un déploiement et ce bug.
Le handler, avant la 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
};
Trois défauts en quatre lignes :
path.normalize() ni path.resolve() n'est appelé. Quels que soient les segments reçus, ils sont concaténés tels quels.filePath résultant se trouve toujours à l'intérieur de UPLOAD_DIRECTORY.+ '/' + traite les composants comme du texte, pas comme un chemin ayant une sémantique.Le résultat va directement dans createReadStream() et les octets sont diffusés à l'appelant avec un type MIME déduit du nom de fichier. Il n'y a ni liste blanche d'extensions ni filtre de contenu.
L'attaque classique est :
GET /uploads/../../../etc/passwd
Sur Postiz, elle renvoie une 404, et cette 404 est la raison même pour laquelle ce bug a survécu jusqu'à sa découverte.
Next.js normalise le chemin de la requête pendant le routage. Les segments ../ bruts sont réduits avant que le routeur décide quel handler invoquer. Au moment où la requête atteint l'attrape-tout, la traversée a déjà été éliminée — soit le chemin se résout vers un endroit sans route correspondante, soit il se résout à l'intérieur de /uploads, les segments point ayant disparu.
Pour quelqu'un qui teste rapidement, cette 404 se lit comme « le framework gère cela ». C'est une défense réelle et fonctionnelle. Le problème n'est pas qu'elle soit absente — c'est où dans le pipeline elle s'exécute.
La correspondance de routes et le handler de requête n'effectuent pas le même nombre de passes de décodage en pourcentage.
Si les séparateurs sont encodés en pourcentage, la séquence n'est pas un séparateur de chemin pendant la correspondance de routes. %2e%2e%2f n'est qu'une chaîne opaque — un texte inerte que le normaliseur n'a aucune raison de toucher. Elle traverse le routage intacte, correspond à l'attrape-tout, puis est décodée en chemin vers les params du handler, où elle redevient ../.
À ce stade, elle est concaténée à UPLOAD_DIRECTORY et transmise à createReadStream() — après avoir franchi le routage, la normalisation et chaque contrôle qui aurait pu l'arrêter.
Variantes fonctionnelles :
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
Le double encodage ne fonctionne pas — %252e reste littéral à travers l'unique passe de décodage et ne devient jamais un point. Exactement une couche d'encodage est le point idéal, ce qui rappelle utilement que « encoder plus fort » n'est pas une stratégie.
L'invariant à retenir :
Un contrôle qui s'exécute avant la fin du décodage ne protège pas le sink.
Une primitive de lecture de fichier est de gravité High à elle seule. Ce qui la rend Critical, c'est ce qu'elle permet d'atteindre.
Étape 1 — lire l'environnement. La configuration du processus Node lui-même est sur disque, à la racine du déploiement. .env permet d'obtenir, entre autres :
JWT_SECRET — la clé de signature des jetons de sessionDATABASE_URL — les identifiants Postgres completsÉtape 2 — forger une session. Postiz signe les jetons de session avec JWT_SECRET en utilisant HS256 via jsonwebtoken. Point critique : les jetons sont émis sans expiresIn, si bien qu'un jeton forgé est valide indéfiniment.