Approfondimento· PHP 8.3

Sotto il cofano: perché Laravel 13.39.0 blocca phar:// in Filesystem::exists()

Laravel 13.39.0 fa restituire false a Filesystem::exists() e isFile() sui percorsi phar://, tranne quelli del phar in esecuzione. Ecco perché un controllo di esistenza può aprire un archivio, cosa cambia nel tuo codice e cosa resta scoperto.

2P Ada Strict · La tecnica
Illustrazione in pixel art con l'etichetta SOTTO IL COFANO, la scritta grande phar:// e sotto la riga Laravel 13.39.0 e il controllo di esistenza, accanto all'avatar di Ada
Crediti: Illustrazione: redazione TuttoPHP

Dalla versione 13.39.0, uscita il 27 ottobre 2026, Filesystem::exists() e Filesystem::isFile() di Laravel restituiscono false per qualunque percorso che inizi con phar://, a meno che non punti dentro il phar in esecuzione.

Perché ti riguarda: se nel tuo codice un percorso che arriva dall’utente (un nome di file, un campo di un form, un parametro di una coda) finisce in exists(), isFile(), missing() o get(), il risultato su phar:// è cambiato. Nella maggior parte delle app non devi fare nulla; se distribuisci un’app come archivio Phar, leggi la sezione sulla migrazione.

Gli esempi sono stati eseguiti con PHP 8.3.6 (CLI) e con i pacchetti illuminate/filesystem 13.38.0 (prima) e 13.39.0 (dopo), installati con Composer.

Il problema: un controllo che apre un archivio

Un Phar (PHP Archive) è un file che contiene altri file, un po’ come uno zip pensato per il PHP, che il PHP sa anche eseguire. Uno stream wrapper è un gestore che il PHP associa a un prefisso di percorso: file:// per il disco, https:// per la rete, phar:// per gli archivi Phar.

Il manuale descrive il wrapper phar:// come un modo per leggere un file dentro l’archivio come se fosse un percorso normale. Due dettagli contano qui. Primo: il wrapper supporta stat(), quindi funziona anche con file_exists() e is_file(), che si basano su quella famiglia di funzioni. Secondo: non è limitato da allow_url_fopen né da allow_url_include, perché non lavora su file remoti.

Ne segue una cosa poco intuitiva: per rispondere “il file esiste?” su un percorso phar://, il PHP deve aprire l’archivio e leggerne l’indice interno (il manifest). Un controllo che sembra innocuo legge quindi il contenuto di un file che magari ha caricato un utente.

Nel 2018 Sam Thomas ha presentato a Black Hat USA una ricerca (“It’s a PHP Unserialization Vulnerability Jim, but Not as We Know It”) che ha reso noto il rischio: un archivio Phar può contenere metadati serializzati, e nelle versioni del PHP di allora l’apertura dell’archivio li deserializzava. Deserializzare significa ricostruire oggetti PHP a partire da una stringa, e questo può eseguire codice delle classi presenti nell’applicazione. Bastava quindi che un percorso controllato dall’utente arrivasse a una semplice funzione di filesystem. Non entro nei dettagli della tecnica: per capire la correzione di Laravel basta il principio.

Cosa ha già cambiato il PHP

Il PHP 8.0 ha accolto la RFC “Don’t automatically unserialize Phar metadata outside getMetadata()”: da allora i metadati vengono deserializzati solo se chiami esplicitamente Phar::getMetadata() (o il metodo omonimo sui singoli file dell’archivio). Il manuale di getMetadata() avverte che leggere i metadati può eseguire codice PHP arbitrario, quindi non va fatto su archivi non attendibili.

Su PHP 8.3.6 ho verificato che file_exists() su un archivio con metadati non li deserializza, mentre getMetadata() sì. Laravel 13 richiede PHP 8.3 o successivo, quindi la strada “classica” del 2018, quella dei metadati deserializzati in automatico, è già chiusa a livello di linguaggio. Il messaggio di commit della PR #62164 afferma che le funzioni di filesystem deserializzano il manifest: sul PHP che ho provato questo non si verifica per i metadati. Il blocco di Laravel va quindi letto come difesa in profondità: l’archivio continua a essere aperto e interpretato, e l’autore della PR collega la modifica alle protezioni già aggiunte in Laravel 13 per cache e sessioni.

Come funziona il blocco

Prima di tutto, cosa fa il PHP da solo. Questo snippet crea un archivio con un file di testo e lo interroga (per crearlo serve phar.readonly=0; nel PHP che ho usato l’impostazione vale 1):

<?php
// Eseguire con: php -d phar.readonly=0 crea.php
$phar = new Phar(__DIR__ . '/demo.phar');
$phar->addFromString('leggimi.txt', "ciao dal phar\n");

$percorso = 'phar://' . __DIR__ . '/demo.phar/leggimi.txt';
var_dump(file_exists($percorso));
echo file_get_contents($percorso);
bool(true)
ciao dal phar

Ora lo stesso percorso passato a Filesystem di Laravel:

<?php
require $argv[1] . '/vendor/autoload.php';

use Illuminate\Filesystem\Filesystem;

$fs = new Filesystem();
$percorso = 'phar://' . __DIR__ . '/demo.phar/leggimi.txt';

var_dump(file_exists($percorso)); // PHP nativo
var_dump($fs->exists($percorso));
var_dump($fs->isFile($percorso));
var_dump($fs->missing($percorso));
== illuminate/filesystem 13.38.0
bool(true)
bool(true)
bool(true)
bool(false)
== illuminate/filesystem 13.39.0
bool(true)
bool(false)
bool(false)
bool(true)

La funzione nativa non cambia; cambiano i metodi di Laravel. Nel sorgente di 13.39.0 exists() e isFile() chiamano un nuovo metodo protetto, hasDangerousStreamWrapper(), prima di file_exists() o is_file(). Il metodo converte il percorso in minuscolo, controlla se inizia con phar:// e, in quel caso, lo considera pericoloso salvo che inizi con il percorso restituito da Phar::running() seguito da una barra. missing() è definito come la negazione di exists(), quindi ora restituisce true. Secondo la descrizione della PR, get() lancia FileNotFoundException, perché usa isFile() internamente.

Migrazione e compatibilità

Percorsi dall’utente. Se usi exists() o isFile() per validare un percorso che arriva da fuori, ora un phar:// viene trattato come inesistente. È il comportamento che vuoi: non serve cambiare codice, basta aggiornare a 13.39.0 o successiva.

App distribuite come Phar. La prima versione del blocco (PR #62164) rifiutava tutti i phar://, compresi i file del tuo stesso archivio. La PR #62174, inclusa nella stessa release, ha corretto il caso: secondo la sua descrizione, rompeva le app compilate in un unico phar (per esempio con app:build di Laravel Zero), che non trovavano più traduzioni, viste e stub. Ora i percorsi dentro Phar::running() sono ammessi. L’ho verificato con un archivio che contiene vendor/ e un file di testo:

<?php
require __DIR__ . '/vendor/autoload.php';

$fs = new Illuminate\Filesystem\Filesystem();

var_dump($fs->exists(Phar::running() . '/leggimi.txt'));
var_dump($fs->exists('phar://' . getenv('ALTRO') . '/leggimi.txt'));
== illuminate/filesystem 13.38.0
bool(true)
bool(true)
== illuminate/filesystem 13.39.0
bool(true)
bool(false)

Il file del phar in esecuzione resta visibile; un altro archivio (qui il demo.phar di prima, passato con la variabile d’ambiente ALTRO) no.

Percorso legittimo verso un altro Phar. Se la tua app legge davvero file da un archivio diverso da quello in esecuzione, exists() e isFile() non ti aiutano più. Le strade sono due: estrarre i file dall’archivio in fase di build o deploy e lavorare su percorsi normali, oppure usare direttamente le funzioni native o la classe Phar su un archivio di cui controlli la provenienza. In entrambi i casi la scelta deve essere esplicita, non un percorso che arriva da un input.

Trappole comuni

Il blocco copre due metodi, non tutta la classe. Su 13.39.0 ho provato isDirectory() e isReadable() con un percorso phar://: restituiscono entrambi true, perché chiamano is_dir() e is_readable() senza controllo. La release non dichiara una copertura più ampia.

<?php
require $argv[1] . '/vendor/autoload.php';

$fs = new Illuminate\Filesystem\Filesystem();
$base = __DIR__ . '/demo.phar';

var_dump($fs->exists('PHAR://' . $base . '/leggimi.txt'));
var_dump($fs->isDirectory('phar://' . $base));
var_dump($fs->isReadable('phar://' . $base . '/leggimi.txt'));
== illuminate/filesystem 13.38.0
bool(true)
bool(true)
bool(true)
== illuminate/filesystem 13.39.0
bool(false)
bool(true)
bool(true)

La prima riga mostra anche che il controllo ignora le maiuscole: PHAR:// viene bloccato come phar://.

Phar::running() dipende dal file che lo chiama. Restituisce l’archivio del file PHP in esecuzione in quel momento. Ho verificato che, se il codice di Laravel sta fuori dal phar (un vendor/ su disco caricato da uno script dentro l’archivio), dal suo punto di vista Phar::running() è vuoto e anche i file del tuo archivio vengono bloccati. L’eccezione funziona quando vendor/ è incluso nel phar, come nelle build di Laravel Zero.

Storage:: non è la stessa cosa. L’autore della PR nota che con la facciata Storage il comportamento è diverso solo perché il normalizzatore di Flysystem altera l’URI phar://, e che questa non è una vera misura di sicurezza. Non contarci come protezione.

Il blocco non sostituisce la validazione. Un percorso scelto dall’utente va comunque vincolato a una directory nota (per esempio con basename() sul nome e un prefisso fisso); il controllo su phar:// è una rete in più, non la prima difesa.

Fonti