PHP-redirect: header(), statuscodes en open redirect

PHP-redirect: header(), statuscodes en open redirect
Kort antwoord

header('Location: /bestemming') gevolgd door exit, altijd allebei. Zonder expliciete statuscode stuurt PHP een 302, gebruik 301 voor een definitieve verhuizing en 303 na het verwerken van een formulier. Een bestemming die de gebruiker aanlevert, moet je valideren, anders open je een redirect naar een externe site.

Redirecten in PHP is een kwestie van twee regels. Wat wél een artikel waard is: de keuze van de statuscode, het stoppen van het script erachter, en de open redirect die je opent zodra de bestemming uit gebruikersinvoer komt.

De minimale redirect

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

Twee punten worden vaak verkeerd begrepen. Ten eerste accepteert Location zowel een absolute URL als een pad vanaf de root: allebei werken ze in elke huidige browser. Ten tweede stuurt PHP zonder expliciete statuscode een 302 Found.

De juiste statuscode kiezen

Code Betekenis Methode blijft behouden Wanneer gebruiken
301 Permanent verplaatst nee (POST wordt GET) Een URL is definitief veranderd
302 Found, tijdelijk nee (POST wordt GET) Historische standaard, schrijf hem liever niet
303 See Other nee, nooit Na een verwerkt formulier
307 Tijdelijk ja Onderhoud, tijdelijke omschakeling
308 Permanent ja URL gewijzigd, met behoud van POST
php
// Een pagina is definitief verhuisd
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// Na de verwerking van een formulier: voorkomt dat de POST bij een reload opnieuw wordt verstuurd
header('Location: /merci', true, 303);
exit;

De 303 na een formulier heeft een naam: Post/Redirect/Get. Zonder die stap vraagt een refresh van de pagina de browser om het formulier opnieuw te versturen, en gaat de bestelling twee keer de deur uit.

Een 301 blijft in de cache van de browser hangen, soms heel lang. Een permanente redirect die je per ongeluk hebt gezet, krijg je moeilijk weer weg: bezoekers die hem ooit hebben ontvangen worden doorgestuurd zonder de server nog te bevragen. Bij twijfel begin je met een 302 of een 307.

Stop het script, altijd

header() zet een header klaar, meer niet. De code erna draait gewoon door en wat die uitvoert, belandt in de body van de respons.

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

Gemeten met Apache en 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 »

De browser volgt de redirect en toont die body niet, waardoor het probleem onzichtbaar blijft. Verstuurd wordt hij wel: curl, een proxy, een log ertussenin of een crawler zien hem gewoon. Op een pagina die een niet-ingelogde bezoeker wegstuurt, gaat precies de inhoud het netwerk op die je wilde afschermen.

die() en exit() zijn hetzelfde

Er doet een bewering de ronde, ook in de vorige versie van dit artikel: die() zou de verbinding sluiten en exit() niet. Dat klopt niet. Het zijn twee schrijfwijzen van dezelfde taalconstructie, en de PHP-documentatie zegt dat met zoveel woorden.

De controle: twee bestanden die op één woord na identiek zijn, geserveerd door dezelfde 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

De twee responses zijn byte voor byte identiek, op de header Date na. De keuze tussen beide is een kwestie van stijl. exit kom je in moderne code vaker tegen, en zonder haakjes schrijven werkt ook.

De valkuil van al verstuurde headers

Dit is de meest voorkomende fout op dit onderwerp. De kleinste byte die vóór header() wordt geschreven, een spatie vóór <?php, een lege regel na ?> in een include, een echo om te debuggen, een BOM aan het begin van het bestand, maakt de redirect onmogelijk.

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

De melding noemt tussen haakjes het schuldige bestand en de regel. Om te testen voor je iets doet:

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

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

Twee regels houden het probleem bij de bron weg: sluit een PHP-bestand nooit af met ?>, en zet niets op het scherm voor je hebt besloten wat de respons wordt.

De open redirect

Dit is het echte beveiligingslek van dit onderwerp, en het ontbreekt in de meeste tutorials. Het patroon “na het inloggen terug naar de vorige pagina” wordt vaak zo geschreven:

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

Een aanvaller stuurt dan een link in de vorm https://votre-site.com/connexion.php?retour=https://site-pirate.example. De link draagt jouw domeinnaam, komt door de mailfilters heen en stelt het slachtoffer gerust, dat vervolgens op een kopie van jouw inlogpagina belandt. Zo werkt een flink deel van de phishingcampagnes.

De oplossing is niet tekens filteren, maar alleen interne bestemmingen accepteren.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * Staat alleen een intern pad toe. Elke absolute URL wordt geweigerd.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Stuurtekens: poging tot header injection
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // Eén enkele “/” aan het begin, niet gevolgd door een “/” of een backslash
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

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

    // Geen path traversal
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

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

Onze eerste versie van deze functie controleerde alleen de dubbele slash aan het begin. Uit de tests bleek dat er drie soorten invoer doorheen kwamen, elke toegevoegde regel is daarop terug te voeren:

  • /site-pirate.example, browsers behandelen de backslash op deze positie als een slash. De meest gebruikte omweg rond een controle die alleen naar // kijkt.
  • /okrnX-Injecte: 1header() zou dit weigeren, maar je zet de waarde beter opzij voor het zover komt.
  • /ok/../../admin, path traversal die in een niet voorziene zone uitkomt.

De vijftien waarden die door de definitieve versie zijn gehaald:

Ontvangen waarde Bestemming
/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 en /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (zonder slash) en de lege string /

Zijn externe bestemmingen echt nodig, dan is de enige veilige vorm een whitelist van domeinen, vergeleken op de host die parse_url() eruit haalt, nooit met str_contains().

Header injection

Een regeleinde in een headerwaarde maakte het vroeger mogelijk om er andere headers achteraan te plakken. PHP filtert regeleindes in header() sinds versie 5.1.2 en gooit een foutmelding. Verder hoef je niets te doen, maar het is een reden te meer om headers niet op te bouwen door gebruikersdata aan elkaar te plakken.

Wanneer je niet in PHP moet redirecten

Een permanente redirect van URL naar URL heeft geen PHP nodig. Op serverniveau is het sneller, de request wekt de interpreter niet, en het blijft werken als de applicatie eruit ligt.

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

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

PHP blijft op zijn plaats zodra de bestemming van een toestand afhangt: sessie, rechten, uitkomst van een verwerking, onderhandelde taal.

Om te onthouden

  • header('Location: …') en dan exit, altijd allebei.
  • 303 na een formulier, 301 alleen als het definitief is.
  • die() en exit() zijn identiek, gemeten en geverifieerd.
  • Een bestemming die van de gebruiker komt, valideer je tegen interne paden: geen absolute URL’s, geen dubbele slash.

De 303 na een formulier gaat vaak samen met een bevestiging: zie een e-mail versturen met PHP. Moet de bestemming in de browser worden bepaald in plaats van op de server, zie variabelen doorgeven van PHP aan JavaScript. Voor de redirect na een bestandsupload, zie een bestand uploaden in PHP.

Zie ook de hub Webontwikkeling en verbinding maken met een database in PHP.

Veelgemaakte fouten

die() en exit() zouden verschillen Onjuiste bewering, aanwezig in de vorige versie van dit artikel. Het zijn twee schrijfwijzen van dezelfde constructie. Twee bestanden die alleen in dat woord verschillen, geven identieke HTTP-responses terug, op de header Date na.
header() zonder exit header() onderbreekt niets. Wat erna komt gaat mee in de body van de respons: onzichtbaar in de browser, maar wel degelijk aanwezig voor curl, een proxy of een crawler.
Open redirect Met header('Location: ' . $_GET['retour']) bouwt een aanvaller een phishinglink die jouw domeinnaam draagt. De bestemming moet als intern pad worden gevalideerd.
Omzeiling via de dubbele slash //site-pirate.example is een protocolrelatieve URL. En /site-pirate.example komt er ook doorheen: browsers behandelen de backslash op die positie als een slash.
Headers al verstuurd Een spatie vóór <?php, een lege regel na ?> of een BOM is al genoeg. Sluit een PHP-bestand nooit af met ?>.
301 in de cache Een permanente redirect die je per ongeluk hebt gezet, blijft staan in de browser van elke bezoeker die hem heeft ontvangen. Bij twijfel een 302 of een 307.

HTTPPHPSécuritéSEO

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.