Fino a Laravel 13.29, Storage::get('../../../.env') veniva rifiutato con un'eccezione e Storage::path('../../../.env') restituiva tranquillamente il percorso assoluto del vostro .env. Stessa facade, stesso disco, stesso argomento, due risposte opposte. La 13.30, uscita il 1° settembre, allinea le due chiamate. Vale la pena capire come è stato possibile, dove il pattern morde davvero e perché il flag di sicurezza giusto, in questo caso, è un'abitudine di progettazione.
Perché due metodi della stessa facade non erano d'accordo
Quasi tutto quello che chiedete a Storage passa da Flysystem: get(), put(), delete(), readStream(). Flysystem normalizza il percorso prima di usarlo e, se dopo la normalizzazione il risultato esce dalla radice del disco, lancia PathTraversalDetected. È la ragione per cui in anni di applicazioni Laravel nessuno si è mai preoccupato troppo di un .. passato a Storage::get(): il framework lo bloccava per voi.
Storage::path() era l'eccezione. Non legge il file, restituisce solo la stringa del percorso nativo, e per farlo passava direttamente da PathPrefixer::prefixPath(), che concatena il prefisso del disco con quello che gli date. Nessuna normalizzazione, nessun controllo. Sul disco local di default, path('../../../.env') risolveva fuori da storage/app fino alla radice del progetto. Lo stesso valeva per i dischi scoped: path('../file.txt') usciva dal prefisso configurato come se non esistesse.
La PR di KIKOmanasijev, mergiata il 27 agosto e rilasciata nella 13.30.0, fa passare anche path() dal WhitespacePathNormalizer che Flysystem costruisce per ogni altra chiamata, con gli stessi default. Il risultato è che path() restituisce esattamente la stringa che il driver calcola internamente: le due chiamate non possono più essere in disaccordo su cosa significhi un percorso.
Dove il pattern morde: il download degli allegati
Il codice che rende pericolosa questa asimmetria è banale, ed è il motivo per cui il pezzo mi interessa. In un gestionale c'è sempre un punto in cui l'utente scarica un documento: una fattura, un allegato, l'esportazione di un report. La versione pigra di quella rotta è questa:
Route::get('/download', function (Request $request) {
return response()->download(
Storage::path($request->query('path'))
);
});
Con ?path=../../../.env quella rotta, fino a due settimane fa, serviva le credenziali del database a chiunque avesse un account. E Storage::path() è proprio il metodo che si usa quando non si vuole leggere il file con Flysystem: quando lo si passa a response()->download(), a un processo esterno tipo ImageMagick o LibreOffice per una conversione, a una libreria PDF che vuole un percorso nativo. Cioè esattamente i casi in cui il file arriva dal disco al mondo esterno, spesso dopo un giro in una coda dove nessuno guarda più chi ha chiesto cosa.
In undici anni di gestionali Laravel ho visto abbastanza rotte di download da poter dire che la variante "il percorso arriva dalla query string" non è rara. Nasce quasi sempre in buona fede: il front-end ha già il percorso perché lo ha ricevuto in una risposta JSON, e passarlo indietro sembra la cosa più semplice. È anche la cosa più sbagliata, e la 13.30 non cambia questo giudizio.
Il fix del framework non è il vostro fix
Aggiornate alla 13.30: è gratis e chiude la falla di path(). Ma la normalizzazione ferma il .., non l'accesso a un file dentro il disco che non appartiene a chi lo chiede. Con ?path=fatture/altro-cliente/2026-03.pdf il percorso è perfettamente legittimo per Flysystem e perfettamente illegittimo per voi. Nessun normalizzatore può saperlo.
La regola che applico da anni è che il percorso su disco non è mai un input. È una colonna della tabella, e l'utente vi passa l'identificativo della riga:
Route::get('/allegati/{allegato}', function (Allegato $allegato) {
Gate::authorize('view', $allegato);
return Storage::disk('allegati')->download($allegato->path, $allegato->nome_originale);
});
Tre cose succedono in tre righe. Il route model binding trasforma l'input in una riga esistente o in un 404. La policy risponde alla domanda che il filesystem non può porsi: questa persona può vedere questo file? E download() passa da Flysystem, quindi anche se un giorno qualcuno scrivesse una riga con un percorso strano, il traversal verrebbe comunque rifiutato. Il fix della 13.30 diventa un terzo strato di difesa, non l'unico.
Se avete davvero bisogno di path() — per un processo esterno, per esempio — la stessa disciplina vale: il percorso viene dal modello, mai dalla richiesta, e conviene comunque verificare che realpath() del risultato inizi con realpath() della radice del disco. È una riga in più che su Laravel < 13.30 è l'unica cosa fra voi e il vostro .env.
Meno posti dove un byte può stare, meno percorsi da difendere
Questa falla mi ha fatto pensare a una scelta fatta in Miraviso, il mio SaaS per saloni di parrucchieri. L'anteprima del taglio viene generata su un server nella UE, previo consenso, e non viene mai scritta su disco: nasce in memoria, viene restituita al tablet del salone e sparisce. Non è una decisione presa pensando al path traversal, è nata dalla privacy — il volto di una cliente non deve sopravvivere alla richiesta che lo ha generato. Ma ha una conseguenza laterale che oggi apprezzo di più: un file che non esiste non ha un percorso, e un percorso che non esiste non può essere né attraversato né indovinato. Lo stesso vale per le note sensibili, che il server conserva come buste cifrate che non può aprire: anche un download indebito restituirebbe qualcosa di illeggibile.
Non è un modello applicabile a tutto. Un gestionale deve conservare le fatture, e le fatture devono essere scaricabili. Ma la domanda da farsi è la stessa che mi sono fatto per Miraviso: questo file ha bisogno di esistere su disco, e per quanto? Ogni file che avete deciso di non scrivere è una rotta di download che non dovete proteggere.
Da fare oggi
Aggiornate alla 13.30 e cercate nel codice Storage::path( e ->path(: per ogni occorrenza risalite all'origine dell'argomento. Se in qualche punto si arriva a $request, quella è la rotta da riscrivere con un binding e una policy, indipendentemente dalla versione del framework. È un audit meno divertente di un campo minato senza indovinelli, ma anche lì la regola è la stessa: ogni casella si apre solo se si può dimostrare che è sicura.