Quando un'applicazione serve file — allegati, documenti, immagini caricate — l'input dell'utente finisce, direttamente o meno, dentro una chiamata al filesystem. Il path traversal sfrutta quel passaggio per uscire dalla cartella consentita e leggere ciò che sta fuori: file di configurazione, chiavi private, credenziali del database.
È una vulnerabilità vecchia di trent'anni, catalogata come CWE-22, e continua a comparire perché la difesa sembra banale e ha almeno quattro modi non ovvi di fallire.
Come si presenta#
GET /download?file=../../etc/passwdGET /download?file=..%2f..%2f.envGET /download?file=%252e%252e%252f%252e%252e%252fconfig.jsonGET /download?file=....//....//etc/shadowGET /download?file=..\..\windows\win.iniLe cinque righe sono lo stesso attacco vestito in cinque modi: sequenza diretta, codifica percentuale, doppia codifica, frammenti che si ricompongono, separatore Windows. Un filtro che ne riconosce una lascia passare le altre.
Perché filtrare le stringhe non funziona#
// ✗ Il filtro a lista di divieti: sembra ragionevole, non regge.function pulisci(nome) { return nome.replace(/\.\.\//g, ''); // toglie i "../"}pulisci('../../etc/passwd'); // → 'etc/passwd' sembra funzionarepulisci('....//....//etc/passwd'); // → '../../etc/passwd' (!)// Rimuovendo il "../" centrale, i frammenti rimasti si ricompongono.// Applicare il replace in ciclo finché non cambia niente chiude questo caso// e ne lascia aperti altri tre: la codifica multipla, i separatori di// sistemi diversi e la normalizzazione Unicode.Il caso ....// è istruttivo perché mostra il difetto strutturale dell'approccio: ogni rimozione crea un testo nuovo, che non è stato ricontrollato. Si può correggere iterando fino al punto fisso, ma è la strada sbagliata — si sta cercando di elencare tutte le forme dell'attacco, e la lista non finisce mai.
Risolvere, poi contenere#
L'approccio corretto non guarda la stringa in arrivo: calcola il percorso assoluto a cui quella stringa porta, e verifica dove è finito.
// Il primo passo giusto: risolvere, poi verificare. path.resolve() applica// i ".." in modo puramente lessicale, senza toccare il disco.const base = path.resolve('/var/www/public');const target = path.resolve(base, richiesto);if (!target.startsWith(base)) return nega(); // ← quasi giustopath.resolve applica i .. in modo puramente lessicale, senza toccare il disco: /var/www/public/../../etc/passwd diventa /etc/passwd, e a quel punto è evidente che è fuori. Questa parte è giusta. Il problema è la riga dopo.
Il difetto di startsWith#
// ✗ Il difetto di startsWith, che passa inosservato per anni.const base = '/var/www/public';'/var/www/public/img/logo.png'.startsWith(base); // true — corretto'/var/www/public-backup/dump.sql'.startsWith(base); // true — SBAGLIATO (!)'/var/www/publications/segreti.pdf'.startsWith(base); // true — SBAGLIATO (!)// Il confronto è fra stringhe, e "public" è prefisso di "public-backup".// Basta una cartella fratella dal nome simile e il contenimento non c'è più.// ✓ Due forme corrette. La prima aggiunge il separatore:const dentro = target === base || target.startsWith(base + path.sep);// ✓ La seconda, preferibile, delega a path.relative: se per raggiungere// il target da base bisogna risalire, il target è fuori.const rel = path.relative(base, target);const dentro2 = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);Il confronto avviene fra stringhe, e public è prefisso di public-backup. Se accanto alla cartella servita esiste una cartella dal nome simile — e nella pratica esiste quasi sempre: public-old, public.bak, publications — il contenimento non c'è.
Delegare a path.relative è la forma più difficile da sbagliare: se per raggiungere il bersaglio partendo dalla base bisogna risalire, il risultato comincia per .., e la risposta è no. Nessun ragionamento sui separatori, nessun caso limite sulle radici di Windows.
L'ordine fra decodifica e controllo#
Il secondo modo di sbagliare non riguarda il controllo ma quando viene fatto:
// L'ordine delle operazioni conta più della loro correttezza.// Il framework decodifica una volta; se il codice decodifica di nuovo,// la doppia codifica passa il controllo e si riapre dopo.// ✗ Controllo prima, decodifica dopo: il controllo lavora su un dato// che non è quello che verrà usato.const nome = req.query.file; // '%2e%2e%2fsegreto'if (nome.includes('..')) return nega(); // non lo trovafs.readFile(decodeURIComponent(nome)); // → '../segreto' (!)// ✓ Decodificare tutto quello che va decodificato, UNA volta,// e da lì in poi lavorare solo sul valore risolto.let nome2;try { nome2 = decodeURIComponent(req.query.file || '');} catch { return nega(); // percentuale malformata}if (nome2.includes('\0')) return nega(); // Node solleva comunque, ma // meglio un rifiuto esplicitoLa regola è che il controllo deve avvenire sull'ultimo valore prima dell'uso. Se dopo la verifica c'è ancora una trasformazione — una decodifica, una normalizzazione Unicode, una concatenazione — il controllo è stato fatto su un dato diverso da quello che finirà nella chiamata al filesystem.
I link simbolici e la finestra TOCTOU#
Terzo modo di sbagliare, e il più sottile. path.resolve non tocca il disco: se dentro la cartella consentita esiste un link simbolico che punta fuori, il contenimento passa e la lettura esce comunque.
// path.resolve() è puramente lessicale: non sa che 'uploads/report.pdf'// è un link simbolico che punta a /etc/shadow. Il contenimento passa,// la lettura esce dal perimetro.const vero = await fs.promises.realpath(target); // segue i linkif (!contenuto(base, vero)) return nega();// Resta però una finestra TOCTOU: fra il realpath e la open, un processo// che scrive nella cartella (per esempio un altro utente che carica file)// può sostituire il percorso con un link.//// La forma priva di quella finestra: aprire senza seguire i link, e// verificare sul descrittore già aperto — non più sul nome.const fd = await fs.promises.open(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);try { const st = await fd.stat(); if (!st.isFile()) return nega(); return fd.createReadStream();} catch (e) { if (e.code === 'ELOOP') return nega(); // era un link simbolico throw e;}Il rischio è concreto ovunque l'applicazione scriva nella cartella che poi legge — i caricamenti degli utenti, l'estrazione di archivi, una cartella condivisa fra container. Quanto vale la pena spingersi dipende dal contesto: se nella cartella scrive solo il vostro processo di deploy, realpath è abbastanza. Se ci scrivono gli utenti, la finestra fra il controllo e l'apertura è sfruttabile e va chiusa aprendo il descrittore per primo.
La stessa falla dentro un archivio: Zip Slip#
// La stessa vulnerabilità, in un vestito diverso: i nomi dentro un archivio// sono input dell'utente esattamente come una querystring.// Un file .zip può contenere una voce chiamata "../../../etc/cron.d/backdoor".for (const voce of archivio.voci) { const destinazione = path.resolve(CARTELLA_ESTRAZIONE, voce.nome); // Senza questo controllo, l'estrazione scrive dove vuole l'archivio. const rel = path.relative(CARTELLA_ESTRAZIONE, destinazione); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error(`voce fuori perimetro: ${voce.nome}`); } if (voce.tipoLink) continue; // i link simbolici dentro un archivio // non hanno usi legittimi qui await scrivi(destinazione, voce.dati);}I nomi dei file dentro un archivio caricato dall'utente sono input dell'utente esattamente come una querystring, ma quasi nessuna libreria di decompressione li valida: l'estrazione scrive dove dice l'archivio. La vulnerabilità è stata catalogata da Snyk nel 2018 con il nome Zip Slip e ha interessato librerie in praticamente ogni linguaggio.
La difesa che rende il problema impossibile#
Tutto quanto sopra serve nei casi in cui un percorso composto dall'utente è inevitabile. Nella maggior parte delle applicazioni non lo è, e la soluzione migliore è togliere il problema:
// La difesa più solida non è un controllo migliore: è togliere all'utente// la possibilità di comporre un percorso.const DOCUMENTI = new Map([ ['listino-2026', { file: 'listino-2026.pdf', nome: 'Listino 2026.pdf' }], ['manuale-base', { file: 'manuale-v3.pdf', nome: 'Manuale.pdf' }]]);app.get('/download/:id', (req, res) => { const doc = DOCUMENTI.get(req.params.id); if (!doc) return res.status(404).end(); // Il percorso non deriva più dall'input: deriva da una tabella che // avete scritto voi. Non c'è niente da attraversare. res.download(path.join(BASE, doc.file), doc.nome);});L'utente non manda più un percorso: manda un identificatore, che viene risolto contro una tabella scritta da voi. Non c'è niente da attraversare, perché non c'è niente da comporre. Per i file caricati dagli utenti, lo stesso schema con una riga di database: l'identificatore è la chiave primaria, il nome sul disco è un UUID generato da voi, il nome originale è solo un'etichetta da mostrare.
E se un percorso relativo serve davvero#
// Se un percorso relativo dall'utente è inevitabile, non riscrivete il// contenimento: Express lo implementa già, ed è codice molto più// collaudato del vostro.app.get('/statici/*percorso', (req, res) => { res.sendFile(req.params.percorso, { root: path.resolve('public'), // obbligatorio: definisce il perimetro dotfiles: 'deny', // niente .env, .git, .htaccess index: false }, (err) => { if (err) res.status(err.status || 404).end(); });});Prima di scrivere il vostro contenimento, verificate che non esista già. res.sendFile con l'opzione root, e il middleware express.static, implementano il controllo — e sono codice esercitato da milioni di installazioni. La regola vale in generale: nella sicurezza, il codice più collaudato batte il codice più recente.
Verificarlo dopo ogni deploy#
# Un controllo di dieci secondi, da fare dopo ogni deploy.for p in '../../etc/passwd' '..%2f..%2f.env' '%252e%252e%252fconfig.json' \> '....//....//etc/passwd' '..\\..\\web.config' '/etc/passwd'; do> printf '%-40s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' \> "https://esempio.it/download?file=$p")"> done../../etc/passwd 400..%2f..%2f.env 400%252e%252e%252fconfig.json 400....//....//etc/passwd 400..\..\web.config 400/etc/passwd 400# Qualunque cosa diversa da 400/403/404 va guardata subito.| Vettore | Cosa lo ferma |
|---|---|
../ diretto | path.resolve + contenimento |
..%2f codificato | decodifica prima del controllo |
%252e%252e%252f doppia codifica | una sola decodifica, e il controllo sul valore finale |
....// ricomposizione | risoluzione, mai rimozione di sottostringhe |
..\ separatore Windows | path.resolve normalizza per la piattaforma |
Cartella fratella public-backup | path.relative, non startsWith |
| Link simbolico verso l'esterno | realpath, o O_NOFOLLOW se la cartella è scrivibile |
| Sostituzione fra controllo e apertura | apertura per prima, verifica sul descrittore |
Voce ../ dentro un archivio | contenimento su ogni voce prima di scrivere |
File nascosti (.env, .git) | dotfiles: 'deny', e non tenerli nella cartella servita |
Il filo conduttore: nessuna delle righe della colonna destra è un filtro sull'input. Sono tutte verifiche sul risultato — su dove si è arrivati, non su cosa è stato chiesto. È la differenza fra cercare di indovinare tutte le forme dell'attacco e limitarsi a controllare l'unica cosa che conta davvero.