
CVE-2026-19264 - Path traversal critico non autenticato fino alla compromissione completa dell'istanza in Postiz (< 2.22.1). Writeup tecnico: bypass dell'ordine di decodifica, escalation di JWT_SECRET e analisi della correzione a monte.

Autore: Krithik Babu P (@DarkLycn1976)
Pubblicato: 2026-08-10
CVE: CVE-2026-19264
Gravità: Critica - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - Limitazione impropria di un pathname a una directory ristretta
Versioni affette: gitroomhq/postiz-app < 2.22.1
Corretta in: v2.22.1
Postiz serviva i media archiviati localmente tramite una route che univa i segmenti di percorso forniti dall'URL alla directory di upload e trasmetteva il risultato al chiamante - senza normalizzazione del percorso, senza controllo di contenimento e senza autenticazione.
Il payload di traversal più ovvio restituisce 404, perché Next.js compatta i segmenti ../ prima del routing. Ma i separatori codificati in URL sopravvivono al matching delle route e vengono decodificati esattamente un'ultima volta lungo il percorso verso la chiamata al filesystem, ripristinando la traversal al di là di ogni controllo.
Un attaccante non autenticato poteva leggere qualsiasi file leggibile dal processo dell'applicazione - incluso il suo stesso ambiente, che contiene il segreto di firma JWT. Poiché Postiz firma i token di sessione con quel segreto e li emette senza un claim di scadenza, il suo recupero trasforma una primitiva di lettura file in una sessione permanente e falsificabile come qualsiasi utente, incluso un amministratore.
Una richiesta GET non autenticata fino alla compromissione totale dell'istanza.
Postiz è una piattaforma open-source di pianificazione per i social media - circa 34.000 stelle su GitHub al momento della stesura - realizzata come frontend Next.js con backend NestJS. È ampiamente auto-ospitata da agenzie e piccoli team per gestire account social connessi, contenuti pianificati e fatturazione.
Le distribuzioni self-hosted possono archiviare i media caricati localmente anziché su un object storage. Questo comportamento è controllato da una singola variabile d'ambiente:
STORAGE_PROVIDER=local
Questo è il valore incluso in .env.example, quindi è ciò che eseguono la maggior parte degli auto-ospitanti a meno che non configurino deliberatamente S3 o Cloudflare R2.
Quando lo storage locale è attivo, next.config.js riscrive il percorso pubblico /uploads/:path* su una route API interna:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
Due proprietà rendono questa route interessante prima ancora che entri in gioco un bug:
[[...path]] fa sì che ogni componente rimanente del percorso arrivi come un array che l'handler è libero di interpretare.Quando STORAGE_PROVIDER è un valore diverso da local, la riscrittura punta a /404 e l'handler è irraggiungibile. Quel gate di configurazione è l'unica cosa che separa una distribuzione da questo bug.
L'handler, prima della 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
};
Tre difetti in quattro righe:
path.normalize(), path.resolve() - nessuno dei due viene chiamato. Qualsiasi segmento arrivi viene concatenato così com'è.filePath risultante si trovi ancora all'interno di UPLOAD_DIRECTORY.+ '/' + tratta i componenti come testo, non come un percorso con una semantica.Il risultato finisce direttamente in createReadStream() e i byte vengono trasmessi al chiamante con un tipo MIME dedotto dal nome del file. Non esiste un'allow-list di estensioni né un filtro sui contenuti.
L'attacco da manuale è:
GET /uploads/../../../etc/passwd
Su Postiz questo restituisce 404, e quel 404 è l'intera ragione per cui questo bug è sopravvissuto fino a quando non è stato trovato.
Next.js normalizza il percorso della richiesta durante il routing. I segmenti ../ grezzi vengono compattati prima che il router decida quale handler invocare. Quando la richiesta raggiunge il catch-all, la traversal è già stata eliminata - o il percorso risolve da qualche parte senza una route corrispondente, oppure risolve nuovamente all'interno di /uploads senza più i segmenti ...
Per chi sta testando rapidamente, quel 404 si legge come "il framework ci pensa lui". È una difesa genuina e funzionante. Il problema non è che sia assente - è dove nella pipeline viene eseguita.
Il matching delle route e l'handler della richiesta non eseguono lo stesso numero di passaggi di percent-decoding.
Se i separatori sono percent-encoded, la sequenza non è un separatore di percorso durante il matching delle route. %2e%2e%2f è solo una stringa opaca - testo inerte che il normalizzatore non ha alcun motivo di toccare. Attraversa il routing intatta, viene fatta corrispondere dal catch-all e viene decodificata mentre entra nei params dell'handler, dove torna a essere ../.
A quel punto viene concatenata a UPLOAD_DIRECTORY e passata a createReadStream() - oltre il routing, oltre la normalizzazione, oltre ogni controllo che l'avrebbe fermata.
Forme funzionanti:
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
Il doppio encoding non funziona - %252e rimane letterale attraverso il singolo passaggio di decodifica e non diventa mai un punto. Esattamente uno strato di encoding è il punto ottimale, un utile promemoria che "codificare di più" non è una strategia.
L'invariante da tenere a mente:
Un controllo eseguito prima che la decodifica sia completa non sta proteggendo il sink.
Una primitiva di lettura file è di per sé di gravità Alta. Ciò che la rende Critica è ciò che riesce a raggiungere.
Passo 1 - leggere l'ambiente. La configurazione stessa del processo Node è su disco nella root della distribuzione. .env fornisce, tra le altre cose:
JWT_SECRET - la chiave di firma dei token di sessioneDATABASE_URL - credenziali Postgres completePasso 2 - falsificare una sessione. Postiz firma i token di sessione con JWT_SECRET usando HS256 tramite jsonwebtoken. In modo critico, i token vengono emessi senza expiresIn, quindi un token falsificato è valido indefinitamente.