Redirect in PHP: header(), codici HTTP e open redirect

Redirect in PHP: header(), codici HTTP e open redirect
Risposta rapida

header('Location: /destinazione') seguito da exit, sempre entrambi. Senza un codice esplicito PHP invia un 302, usa 301 per uno spostamento definitivo e 303 dopo l'elaborazione di un form. Una destinazione fornita dall'utente va validata, altrimenti apri un redirect verso un sito di terzi.

Fare un redirect in PHP sta in due righe. Quello che merita un articolo è la scelta del codice HTTP, l’arresto dello script subito dopo e la falla di open redirect che nasce da ogni redirect costruito su un dato inviato dall’utente.

Il redirect minimo

php
<?php
header('Location: /nouvelle-page');
exit;

Due punti spesso fraintesi. Primo, Location accetta sia un URL assoluto sia un percorso relativo alla radice: entrambi funzionano su tutti i browser attuali. Secondo, senza un codice esplicito PHP invia un 302 Found.

Scegliere il codice giusto

Codice Significato Metodo conservato Quando usarlo
301 Spostato definitivamente no (il POST diventa GET) Un URL è cambiato per sempre
302 Trovato, temporaneo no (il POST diventa GET) Default storico, meglio non scriverlo
303 Vedi altrove no, mai Dopo l’elaborazione di un form
307 Temporaneo sì Manutenzione, deviazione provvisoria
308 Permanente sì URL cambiato, preservando il POST
php
// Una pagina ha traslocato definitivamente
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// Dopo l'elaborazione di un form: evita il reinvio del POST al ricaricamento
header('Location: /merci', true, 303);
exit;

Il 303 dopo un form ha un nome: Post/Redirect/Get. Senza, un aggiornamento della pagina chiede di nuovo al browser di reinviare il form, e l’ordine parte due volte.

Il 301 finisce nella cache del browser, a volte per moltissimo tempo. Un redirect permanente messo per sbaglio è fastidioso da togliere: i visitatori che l’hanno ricevuto continuano a essere reindirizzati senza nemmeno interrogare il server. Nel dubbio, parti da un 302 o da un 307.

Fermare sempre lo script

header() imposta un header, non interrompe niente. Il codice che segue viene eseguito e quello che stampa finisce nel corpo della risposta.

fuite.php
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT";   // partito lo stesso

Misurato con Apache e PHP 8.5.10:

code
avec exit  -> corps de la réponse : 0 octet
sans exit  -> corps de la réponse : 16 octets, « CONTENU QUI FUIT »

Il browser segue il redirect e non mostra quel corpo, il che rende il problema invisibile. Ma viene trasmesso lo stesso: curl, un proxy, un log intermedio o un bot lo vedono. Su una pagina che reindirizza un visitatore non autenticato, finisce in rete esattamente il contenuto che volevi proteggere.

die() ed exit() sono la stessa cosa

Gira parecchio un’affermazione, presente anche nella versione precedente di questo articolo: die() chiuderebbe la connessione, exit() la lascerebbe aperta. È falso. Sono due scritture dello stesso costrutto del linguaggio, e la documentazione PHP lo dice esplicitamente.

Verifica: due file identici a parte una parola, serviti dallo stesso Apache:

php
<?php header('HTTP/1.1 304 Not Modified'); exit();   // avec-exit.php
<?php header('HTTP/1.1 304 Not Modified'); die();    // avec-die.php
code
$ curl -s -D - -o /dev/null http://localhost/avec-exit.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

$ curl -s -D - -o /dev/null http://localhost/avec-die.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

Le due risposte sono identiche byte per byte, header Date a parte. Scegliere tra le due è una questione di stile. exit è più diffuso nel codice moderno, e scriverlo senza parentesi funziona lo stesso.

La trappola degli header già inviati

È l’errore più frequente su questo argomento. Basta un byte scritto prima di header(), uno spazio prima di <?php, una riga vuota dopo ?> in un file incluso, un echo di debug, un byte BOM in testa al file, e il redirect diventa impossibile.

code
Warning: Cannot modify header information - headers already sent by
(output started at /var/www/inc/config.php:42) in /var/www/page.php on line 3

Il messaggio indica tra parentesi il file e la riga colpevoli. Per verificare prima di agire:

php
if (headers_sent($fichier, $ligne)) {
    error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
    // ripiego: redirect lato browser
    echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
    exit;
}

header('Location: /nouvelle-page', true, 303);
exit;

Due regole evitano il problema alla radice: non chiudere mai un file PHP con ?> e non stampare niente prima di aver deciso la risposta.

L’open redirect

È la vera falla di sicurezza di questo argomento, e manca nella maggior parte dei tutorial. Il classico «torna alla pagina precedente dopo il login» si scrive spesso così:

php
// Vulnerabile
header('Location: ' . $_GET['retour']);
exit;

A quel punto un attaccante manda un link della forma https://votre-site.com/connexion.php?retour=https://site-pirate.example. Il link porta il tuo nome di dominio, supera i filtri della posta e tranquillizza la vittima, che atterra su una copia della tua pagina di login. È il meccanismo di base di buona parte delle campagne di phishing.

La correzione non consiste nel filtrare i caratteri, ma nell’accettare solo destinazioni interne.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * Consente solo un percorso interno. Ogni URL assoluto viene scartato.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Caratteri di controllo: tentativo di header injection
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // Una sola «/» in testa, non seguita da «/» né da «\»
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

    $parties = parse_url($demande);
    if ($parties === false || isset($parties['scheme']) || isset($parties['host'])) {
        return $defaut;
    }

    // Nessuna risalita di percorso
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

function redirigerVers(string $demande, string $defaut = '/'): never
{
    header('Location: ' . destinationSure($demande, $defaut), true, 303);
    exit;
}

La nostra prima versione di questa funzione controllava solo il doppio slash iniziale. Le prove hanno mostrato che lasciava passare tre famiglie di valori, e ognuna delle righe aggiunte serve a chiuderle:

  • /site-pirate.example, i browser trattano il backslash come uno slash in questa posizione. È l’aggiramento più comune di un controllo che guarda solo //.
  • /okrnX-Injecte: 1 — header() lo rifiuterebbe, ma è meglio scartare il valore prima di arrivare lì.
  • /ok/../../admin, una risalita di percorso che raggiunge una zona non prevista.

I quindici valori passati alla versione finale:

Valore ricevuto Destinazione
/mon-compte /mon-compte
/articles?page=2 /articles?page=2
/a/b?x=1#ancre /a/b?x=1#ancre
https://site-pirate.example /
//site-pirate.example /
/site-pirate.example /
javascript:alert(1) /
/../admin e /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (senza slash) e stringa vuota /

Se servono davvero destinazioni esterne, l’unica forma sicura è una whitelist di domini, confrontata sull’host estratto da parse_url(), mai con str_contains().

L’header injection

Un a capo dentro il valore di un header permetteva un tempo di aggiungerne altri. PHP filtra gli a capo in header() dalla versione 5.1.2 e solleva un errore. Non serve fare altro, ma è un motivo in più per non costruire gli header concatenando dati dell’utente.

Quando non fare il redirect in PHP

Un redirect permanente da URL a URL non ha bisogno di PHP. Farlo a livello di server è più veloce, la richiesta non sveglia l’interprete, e sopravvive a un guasto dell’applicazione.

.htaccess
Redirect 301 /ancienne-page /nouvelle-page

# Forzare HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
nginx.conf
location = /ancienne-page {
    return 301 /nouvelle-page;
}

PHP resta la scelta giusta quando la destinazione dipende da uno stato: sessione, permessi, esito di un’elaborazione, lingua negoziata.

Da ricordare

  • header('Location: …') poi exit, sempre entrambi.
  • 303 dopo un form, 301 solo quando è definitivo.
  • die() ed exit() sono identici, misurato e verificato.
  • Una destinazione che arriva dall’utente va validata sui percorsi interni, rifiutando gli URL assoluti e il doppio slash.

Il 303 dopo un form va spesso insieme a una conferma di ricezione: vedi inviare un’email con PHP. Se la destinazione va calcolata lato browser invece che lato server, vedi passare variabili da PHP a JavaScript. Per il redirect che segue l’upload di un file, vedi upload di un file in PHP.

Vedi anche l’hub Sviluppo web e la connessione a un database in PHP.

Errori frequenti

die() ed exit() sarebbero diversi Affermazione falsa, presente nella versione precedente di questo articolo. Sono due scritture dello stesso costrutto. Due file identici a parte questa parola restituiscono risposte HTTP identiche, header Date a parte.
header() senza exit header() non interrompe niente. Il contenuto che segue parte nel corpo della risposta: invisibile nel browser, ben presente per curl, un proxy o un bot.
Open redirect header('Location: ' . $_GET['retour']) permette di fabbricare un link di phishing che porta il tuo nome di dominio. La destinazione va validata come percorso interno.
Aggiramento con il doppio slash //site-pirate.example è un URL relativo al protocollo. E passa anche /site-pirate.example: i browser trattano il backslash come uno slash in questa posizione.
Header già inviati Bastano uno spazio prima di <?php, una riga vuota dopo ?> o un byte BOM. Non chiudere mai un file PHP con ?>.
301 messo in cache Un redirect permanente messo per sbaglio resta nel browser dei visitatori che l'hanno ricevuto. Nel dubbio, un 302 o un 307.

HTTPPHPSécuritéSEO

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.