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#

tentativi tipiciHTTP
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.ini

Le 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#

filtro.jsJavaScript
// ✗ 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.

contenimento.jsJavaScript
// 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 giusto

path.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#

startswith.jsJavaScript
// ✗ 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:

decodifica.jsJavaScript
// 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 esplicito

La 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.

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.

symlink.jsJavaScript
// 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#

estrazione.jsJavaScript
// 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:

elenco.jsJavaScript
// 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#

sendfile.jsJavaScript
// 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#

verifica
# 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.
Vale la pena inserirlo nello script di controllo post-deploy: una configurazione del proxy cambiata o una rotta aggiunta in fretta possono riaprire quello che era chiuso.
VettoreCosa lo ferma
../ direttopath.resolve + contenimento
..%2f codificatodecodifica prima del controllo
%252e%252e%252f doppia codificauna sola decodifica, e il controllo sul valore finale
....// ricomposizionerisoluzione, mai rimozione di sottostringhe
..\ separatore Windowspath.resolve normalizza per la piattaforma
Cartella fratella public-backuppath.relative, non startsWith
Link simbolico verso l'esternorealpath, o O_NOFOLLOW se la cartella è scrivibile
Sostituzione fra controllo e aperturaapertura per prima, verifica sul descrittore
Voce ../ dentro un archiviocontenimento 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.