
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
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ć |
// 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.
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT"; // i tak poleciałoZmierzone na Apache’u i PHP 8.5.10:
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 header('HTTP/1.1 304 Not Modified'); exit(); // avec-exit.php
<?php header('HTTP/1.1 304 Not Modified'); die(); // avec-die.php$ 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.10Obie 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.
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 3Komunikat podaje w nawiasie winny plik i numer linii. Żeby sprawdzić sytuację, zanim zaczniesz działać:
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:
// 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.
<?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: 1—header()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.
Redirect 301 /ancienne-page /nouvelle-page
# Wymuszenie HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]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 nimexit, zawsze oba.- 303 po formularzu, 301 tylko wtedy, gdy zmiana jest ostateczna.
die()iexit()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
Date.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.header('Location: ' . $_GET['retour']) pozwala spreparować link phishingowy z twoją domeną. Cel trzeba zwalidować jako ścieżkę wewnętrzną.//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.<?php, pusta linia po ?> albo bajt BOM. Nigdy nie zamykaj pliku PHP znacznikiem ?>.

