+39 3662317539/+34 620899163
info@simonecosci.com

PHP 8.5: l’estensione Uri al posto di parse_url

Sviluppo Siti Web a Tenerife

PHP 8.5: l’estensione Uri al posto di parse_url

Per vent’anni, in PHP, un URL si è spezzato in parti con parse_url(). Funziona quasi sempre, e il problema è proprio quel “quasi”. Il manuale di PHP oggi lo dice senza giri di parole: la funzione “does not follow any established URI or URL standard” e “will return incorrect or non-sense results for relative or malformed URLs”. Non serve nemmeno a validare: “This function is not meant to validate the given URL, it only breaks it up into the parts”.

Con PHP 8.5, uscito il 20 novembre 2025, c’è finalmente un’alternativa che segue gli standard. È l’estensione uri, che le note di migrazione descrivono come “always enabled”: non va installata, non va abilitata nel php.ini. La RFC che l’ha introdotta è passata con 30 voti a favore e uno contrario.

Il problema vero: due parser, due risposte

L’errore di parsing in sé è il meno. Il punto che il manuale mette in evidenza è un altro:

Processing an URL with parsers following different URL standards is a common source of security vulnerabilities. As an example, validating an URL against an allow-list of acceptable hostnames with parser A might be ineffective when the actual retrieval of the resource uses parser B that extracts hostnames differently.

È lo schema classico di un controllo che si fa aggirare: il codice controlla l’host con parse_url(), lo trova nella lista dei domini ammessi, poi passa la stringa a cURL o a un client HTTP, che la legge con regole sue e va da un’altra parte. Nessuno dei due ha sbagliato secondo le proprie regole. Hanno solo regole diverse.

La raccomandazione del manuale è esplicita: usare le nuove classi “for all newly written code” e migrare gli usi esistenti di parse_url(), salvo dove serve conservarne il comportamento per compatibilità.

Due classi, due standard

L’estensione non offre un parser, ne offre due, perché gli standard sono due:

  • Uri\Rfc3986\Uri segue la RFC 3986, la specifica generica degli URI. La RFC di PHP la definisce “relatively permissive” e fa notare che è lo standard seguito anche da cURL.
  • Uri\WhatWg\Url segue il WHATWG URL Standard, quello dei browser: la RFC lo descrive come “mostly relevant in the web browser context”.

Entrambe sono final readonly, quindi immutabili. Il passaggio da parse_url() è breve. Questo è l’esempio della pagina di rilascio di PHP 8.5:

use Uri\Rfc3986\Uri;

$uri = new Uri('https://php.net/releases/8.5/en.php');

var_dump($uri->getHost());
// string(7) "php.net"

La differenza tra le due si vede appena l’input non è pulito. WHATWG normalizza già durante il parsing, come fa un browser:

$url = new Uri\WhatWg\Url("HTTPS://////EXAMPLE.com");
echo $url->toAsciiString(); // https://example.com/

RFC 3986 invece conserva l’input com’era e ti dà due versioni di ogni componente, quella grezza e quella normalizzata:

$uri = new Uri\Rfc3986\Uri("HTTPS://EXAMPLE.COM/foo/../bar/");
echo $uri->getRawPath(); // /foo/../bar/
echo $uri->getPath();    // /bar/

La normalizzazione, dice il manuale, converte in maiuscolo le triplette esadecimali, decodifica i caratteri non riservati codificati con %, porta schema e host in minuscolo e rimuove i segmenti . e .. dal percorso. Gli esempi della RFC: con ex%61mple.com, getRawHost() restituisce ex%61mple.com e getHost() restituisce example.com. toRawString() ricompone l’URI così come è arrivato, toString() in forma normalizzata. La versione grezza è quella che serve quando l’URL va firmato o riprodotto byte per byte, e la RFC cita proprio “API clients or signers”.

Quale scegliere

La regola che segue dal manuale è semplice: controlla l’URL con lo stesso standard di chi poi lo userà.

  • Se l’URL lo scrive un utente in un form, o arriva da un link nel browser, Uri\WhatWg\Url lo legge come lo legge il browser.
  • Se l’URL lo passi a cURL o a un client che segue la RFC 3986, controllalo con Uri\Rfc3986\Uri.

C’è anche un caso pratico che decide da solo: gli host internazionalizzati. La RFC mostra che Uri\Rfc3986\Uri::parse("https://你好你好.com") restituisce null, perché la RFC 3986 non ammette caratteri Unicode nell’host. Uri\WhatWg\Url ha due getter apposta, getAsciiHost() e getUnicodeHost(), e due metodi per ricomporre l’URL, toAsciiString() e toUnicodeString().

Gestire l’input non valido

Ci sono due modi per costruire l’oggetto, e la differenza conta:

use Uri\WhatWg\Url;

// Il costruttore lancia un'eccezione se l'URL non è valido
try {
    $url = new Url($input);
} catch (Uri\WhatWg\InvalidUrlException $e) {
    // $e->errors contiene gli errori di validazione
}

// parse() restituisce null, senza eccezioni
$url = Url::parse($input);
if ($url === null) {
    // URL non valido
}

Per la classe RFC 3986 l’eccezione è Uri\InvalidUriException, da cui Uri\WhatWg\InvalidUrlException eredita: un catch sulla prima le prende tutte e due. La RFC raccomanda parse() “for validating URIs and/or parsing URIs from untrusted input”.

WHATWG ha in più gli errori “soft”: problemi che lo standard corregge da solo e che il parser comunque ti segnala. L’esempio della RFC, con uno spazio davanti:

$errors = [];
$url = Uri\WhatWg\Url::parse(" https://example.org", null, $errors);
echo $url->toAsciiString(); // https://example.org
var_dump($errors[0]->type);
// enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)

Ogni Uri\WhatWg\UrlValidationError ha la proprietà failure: true se l’errore ha fatto scartare l’URL, false se è stato “automatically corrected during parsing”. Se l’URL arriva da un utente, quell’array è un buon posto da mettere nei log.

Modificare e confrontare

Gli oggetti sono immutabili: i metodi with*() restituiscono una copia nuova e lasciano intatto l’originale. Dal manuale:

$uri = new \Uri\Rfc3986\Uri("https://example.com?foo=bar");
$uri = $uri->withQuery("foo=baz");

echo $uri->getQuery(); // foo=baz

Per gli URL relativi c’è resolve(), che li applica all’oggetto corrente come base:

$uri = new \Uri\Rfc3986\Uri("https://example.com");
$uri = $uri->resolve("/foo");

echo $uri->toRawString(); // https://example.com/foo

E per confrontare due URL c’è equals(), che guarda all’equivalenza e non ai caratteri: nell’esempio del manuale, https://example.com e HTTPS://example.com danno bool(true). Il secondo argomento decide se contare anche il frammento (#...): di default è Uri\UriComparisonMode::ExcludeFragment, quindi no.

Cosa evitare

  • Controllare con un parser e scaricare con un altro. È il caso che il manuale cita come fonte comune di vulnerabilità. Vale anche dopo la migrazione: se validi con Url e poi passi la stringa originale a un client RFC 3986, il problema è ancora lì, solo spostato. Più sicuro passare avanti l’URL ricomposto dall’oggetto, non l’input dell’utente.
  • Il costruttore su input non fidato senza try. Un URL malformato in un form diventa un’eccezione non gestita. Su input esterno, parse() e un controllo su null.
  • Confrontare URL come stringhe. HTTPS://EXAMPLE.COM e https://example.com sono lo stesso indirizzo; === dice di no. E ricorda che equals() di default ignora il frammento.
  • Mischiare le classi. equals() accetta solo oggetti dello stesso standard: un Uri non si confronta con un Url.
  • Pensare che parse_url() sparisca. Non è deprecata: la RFC dice solo che “can be deprecated at some distant point of time”. Il codice vecchio continuerà a funzionare, e proprio per questo va migrato di proposito, partendo dai punti in cui un URL decide cosa è permesso e cosa no.

Se hai un’applicazione PHP che controlla URL forniti dagli utenti (redirect dopo il login, webhook, import da indirizzi esterni) e vuoi capire dove parse_url() sta facendo da guardiano, scrivici.