Przekierowanie w PHP: header(), kody statusu i open redirect

Przekierowanie w PHP: header(), kody statusu i open redirect
Szybka odpowiedź

header('Location: /cel'), a zaraz po nim exit, zawsze oba. Bez jawnie podanego kodu PHP wysyła 302, użyj 301 przy przeniesieniu na stałe i 303 po przetworzeniu formularza. Cel podany przez użytkownika trzeba zwalidować, inaczej otwierasz przekierowanie na obcą stronę.

Przekierowanie w PHP mieści się w dwóch liniach. Na artykuł zasługuje reszta: wybór kodu HTTP, zatrzymanie skryptu tuż za nim i podatność open redirect, którą otwiera każde przekierowanie zbudowane z danych przysłanych przez użytkownika.

Minimalne przekierowanie

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

Dwie rzeczy bywają źle rozumiane. Po pierwsze, Location przyjmuje zarówno adres bezwzględny, jak i ścieżkę względem katalogu głównego: obie formy działają we wszystkich dzisiejszych przeglądarkach. Po drugie, bez jawnie podanego kodu PHP wysyła 302 Found.

Wybór właściwego kodu

Kod Znaczenie Metoda zachowana Kiedy go używać
301 Przeniesione na stałe nie (POST staje się GET) Adres zmienił się na dobre
302 Znaleziono, tymczasowo nie (POST staje się GET) Historyczna wartość domyślna, lepiej jej nie wpisywać
303 Zobacz gdzie indziej nie, nigdy Po przetworzeniu formularza
307 Tymczasowe tak Prace serwisowe, chwilowe przełączenie
308 Trwałe tak Adres się zmienił, a POST ma zostać
php
// Strona przeniesiona na stałe
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// Po przetworzeniu formularza: zapobiega ponownemu wysłaniu POST przy odświeżeniu
header('Location: /merci', true, 303);
exit;

Kod 303 po formularzu ma swoją nazwę: Post/Redirect/Get. Bez niego odświeżenie strony każe przeglądarce wysłać formularz jeszcze raz, a zamówienie leci dwa razy.

Kod 301 zapisuje się w cache przeglądarki, czasem na bardzo długo. Trwałe przekierowanie postawione przez pomyłkę ciężko potem wycofać: odwiedzający, którzy je dostali, są przekierowywani dalej, nawet nie pytając serwera. W razie wątpliwości zacznij od 302 albo 307.

Zawsze zatrzymuj skrypt

header() ustawia nagłówek, niczego nie przerywa. Kod, który następuje po nim, wykonuje się dalej, a to, co wypisze, trafia do treści odpowiedzi.

fuite.php
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT";   // i tak poleciało

Zmierzone na Apache’u i 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 »

Przeglądarka podąża za przekierowaniem i tej treści nie pokazuje, więc problem pozostaje niewidoczny. Ale treść naprawdę wychodzi na sieć: widzi ją curl, proxy, pośredni log albo robot. Na stronie, która odsyła niezalogowanego gościa, wycieka dokładnie to, co miało być chronione.

die() i exit() to jedno i to samo

Krąży twierdzenie, obecne również w poprzedniej wersji tego artykułu: die() miałoby zamykać połączenie, a exit() zostawiać je otwarte. To nieprawda. To dwa zapisy tej samej konstrukcji językowej i dokumentacja PHP mówi to wprost.

Sprawdzenie: dwa pliki różniące się jednym słowem, serwowane przez ten sam 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

Obie odpowiedzi są identyczne bajt w bajt, pomijając nagłówek Date. Wybór między nimi to kwestia stylu. exit częściej pojawia się we współczesnym kodzie i działa też zapisane bez nawiasów.

Pułapka wysłanych już nagłówków

To najczęstszy błąd w tym temacie. Wystarczy jeden bajt zapisany przed header(), spacja przed <?php, pusta linia po ?> w dołączanym pliku, echo zostawione po debugowaniu, bajt BOM na początku pliku, i przekierowanie jest już niemożliwe.

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

Komunikat podaje w nawiasie winny plik i numer linii. Żeby sprawdzić sytuację, zanim zaczniesz działać:

php
if (headers_sent($fichier, $ligne)) {
    error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
    // awaryjnie: przekierowanie po stronie przeglądarki
    echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
    exit;
}

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

Dwie zasady rozwiązują problem u źródła: nigdy nie zamykaj pliku PHP znacznikiem ?> i nic nie wypisuj, zanim nie zdecydujesz o kształcie odpowiedzi.

Open redirect

To prawdziwa dziura bezpieczeństwa w tym temacie i brakuje jej w większości poradników. Wzorzec „wróć na poprzednią stronę po zalogowaniu” koduje się zwykle tak:

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

Atakujący wysyła wtedy link postaci https://votre-site.com/connexion.php?retour=https://site-pirate.example. Link niesie twoją domenę, przechodzi przez filtry pocztowe i usypia czujność ofiary, która ląduje na kopii twojej strony logowania. To podstawowy mechanizm sporej części kampanii phishingowych.

Poprawka nie polega na filtrowaniu znaków, tylko na przyjmowaniu wyłącznie celów wewnętrznych.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * Dopuszcza wyłącznie ścieżkę wewnętrzną. Każdy adres bezwzględny zostaje odrzucony.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Znaki sterujące: próba wstrzyknięcia nagłówka
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // Pojedynczy „/” na początku, po którym nie ma ani „/”, ani odwrotnego ukośnika
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

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

    // Bez wychodzenia w górę ścieżki
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

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

Nasza pierwsza wersja tej funkcji sprawdzała tylko podwójny ukośnik na początku. Testy pokazały, że przepuszcza trzy rodziny wejść, i stąd każda z dopisanych linii:

  • /site-pirate.example, przeglądarki traktują w tym miejscu odwrotny ukośnik jak zwykły. To najpopularniejsze obejście kontroli, która patrzy wyłącznie na //.
  • /okrnX-Injecte: 1header() by to odrzucił, ale lepiej odsiać taką wartość, zanim tam trafi.
  • /ok/../../admin, wyjście w górę ścieżki, które trafia w nieprzewidziany obszar.

Piętnaście wejść przepuszczonych przez wersję końcową:

Otrzymana wartość Cel
/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 i /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (bez ukośnika) i pusty ciąg /

Jeśli zewnętrzne cele są naprawdę potrzebne, jedyną bezpieczną formą jest biała lista domen porównywana z hostem wyciągniętym przez parse_url(), nigdy przez str_contains().

Wstrzyknięcie do nagłówka

Znak nowej linii w wartości nagłówka pozwalał kiedyś dopisać kolejne nagłówki. PHP filtruje znaki nowej linii w header() od wersji 5.1.2 i zgłasza błąd. Nic więcej robić nie trzeba, ale to kolejny powód, żeby nie budować nagłówków przez sklejanie danych od użytkownika.

Kiedy nie przekierowywać w PHP

Trwałe przekierowanie z adresu na adres nie potrzebuje PHP. Zrobione na poziomie serwera jest szybsze, żądanie nie budzi interpretera, i przeżywa awarię aplikacji.

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

# Wymuszenie 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 zostaje właściwym miejscem wtedy, gdy cel zależy od stanu: sesji, uprawnień, wyniku przetwarzania, wynegocjowanego języka.

Do zapamiętania

  • header('Location: …'), a zaraz po nim exit, zawsze oba.
  • 303 po formularzu, 301 tylko wtedy, gdy zmiana jest ostateczna.
  • die() i exit() są identyczne, zmierzone i sprawdzone.
  • Cel pochodzący od użytkownika waliduje się względem ścieżek wewnętrznych, odrzucając adresy bezwzględne i podwójny ukośnik.

Kod 303 po formularzu często idzie w parze z potwierdzeniem: zobacz wysyłanie e-maila w PHP. Jeśli cel ma być wyliczany po stronie przeglądarki, a nie serwera, zobacz przekazywanie zmiennych z PHP do JavaScriptu. O przekierowaniu po wysłaniu pliku przeczytasz w upload pliku w PHP.

Zobacz też hub Programowanie webowe oraz połączenie z bazą danych w PHP.

Częste błędy

die() i exit() rzekomo się różnią Fałszywe twierdzenie, obecne w poprzedniej wersji tego artykułu. To dwa zapisy tej samej konstrukcji. Dwa pliki różniące się tylko tym słowem zwracają identyczne odpowiedzi HTTP, pomijając nagłówek Date.
header() bez exit header() niczego nie przerywa. Treść, która następuje po nim, leci w ciele odpowiedzi: niewidoczna w przeglądarce, ale jak najbardziej obecna dla curl, proxy czy robota.
Open redirect header('Location: ' . $_GET['retour']) pozwala spreparować link phishingowy z twoją domeną. Cel trzeba zwalidować jako ścieżkę wewnętrzną.
Obejście przez podwójny ukośnik //site-pirate.example to adres względny wobec protokołu. A /site-pirate.example przechodzi tak samo: przeglądarki traktują w tym miejscu odwrotny ukośnik jak zwykły.
Nagłówki już wysłane Wystarczy spacja przed <?php, pusta linia po ?> albo bajt BOM. Nigdy nie zamykaj pliku PHP znacznikiem ?>.
301 w cache Trwałe przekierowanie postawione przez pomyłkę zostaje w przeglądarkach tych, którzy je dostali. W razie wątpliwości 302 albo 307.

HTTPPHPSécuritéSEO

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.