PHP: Wie ermittelt man die aktuelle URL?
Eine fertige Variable mit der aktuellen URL gibt es in PHP nicht. Man setzt sie
aus mehreren Teilen von $_SERVER zusammen. Der Codeschnipsel dafür steht
überall — und er enthält eine Sicherheitslücke, die man ihm nicht ansieht.
Die verbreitete Lösung
<?php
$schema = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
? 'https' : 'http';
$url = $schema . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
?>
Die funktioniert. Ich habe sie hinter nginx mit PHP-FPM getestet, einmal über HTTP und einmal über HTTPS.
Über HTTP:
HTTPS (nicht gesetzt)
HTTP_HOST localhost:8083
SERVER_NAME
SERVER_PORT 80
REQUEST_URI /url.php
QUERY_STRING
ERMITTELT http://localhost:8083/url.php
Über HTTPS:
HTTPS on
HTTP_HOST localhost:8443
SERVER_PORT 443
REQUEST_URI /url.php?a=1
ERMITTELT https://localhost:8443/url.php?a=1
Sieht gut aus. Und jetzt derselbe Aufruf noch einmal, mit einem einzigen
zusätzlichen Parameter an curl:
user@pc:~$ curl -H 'Host: boese.example' http://localhost:8083/url.php
HTTP_HOST boese.example
ERMITTELT http://boese.example/url.php
Warum das ein Problem ist
HTTP_HOST ist nichts anderes als der Host:-Header der Anfrage. Wie bei
jedem HTTP-Header bestimmt der Client, was drinsteht. PHP schreibt ihn nur in
$_SERVER.
Solange du die URL nur ins Log schreibst, ist das ärgerlich, aber harmlos. Interessant wird es in diesen Fällen:
- Passwort-Zurücksetzen. Du baust den Link mit der ermittelten URL
zusammen und schickst ihn per E-Mail. Ein Angreifer fordert ein Zurücksetzen
für ein fremdes Konto an, setzt den
Host:-Header auf seinen eigenen Server — und bekommt das Token frei Haus geliefert, sobald das Opfer klickt. - Weiterleitungen.
header('Location: ' . $url)schickt den Besucher dorthin, wo der Angreifer es gerne hätte. - Cache Poisoning. Steht ein Cache davor, landet die manipulierte URL in Seiten, die auch andere Besucher bekommen.
- Absolute URLs im HTML. Jedes
<link>, jedes<script src>, jedes Formular-actionzeigt plötzlich woanders hin.
Was gegen SERVER_NAME spricht
Als Ausweg wird oft SERVER_NAME genannt. Das ist der Wert, den der Webserver
konfiguriert hat, und den kann der Client nicht setzen. Nur:
SERVER_NAME
In meinem nginx-Aufbau ist er leer. Die Konfiguration setzt kein
server_name, und dann liefert PHP eben nichts.
Also habe ich denselben Test noch einmal gegen Apache laufen lassen, mit dem
offiziellen php:8.5-apache-Image und ohne irgendetwas zu konfigurieren.
Normal aufgerufen:
HTTP_HOST localhost:8084
SERVER_NAME localhost
SERVER_PORT 8084
Und mit demselben gefälschten Host:-Header wie oben:
HTTP_HOST boese.example
SERVER_NAME boese.example <- bewegt sich mit
SERVER_PORT 80
SERVER_NAME übernimmt den gefälschten Wert. Der Grund ist die Direktive
UseCanonicalName, die standardmäßig auf Off steht — und dann füllt Apache
SERVER_NAME aus dem Host:-Header. Die Empfehlung “nimm SERVER_NAME statt
HTTP_HOST” bringt in dieser Konfiguration also überhaupt nichts.
Beachtenswert ist noch SERVER_PORT: Im gefälschten Fall steht dort 80
statt 8084, weil mein Host:-Header keinen Port enthielt. Auch dieser Wert
lässt sich also beeinflussen.
SERVER_NAME ist damit nur dann sicher, wenn dein Webserver ihn explizit
gesetzt bekommt — bei Apache heißt das ServerName und
UseCanonicalName On. Prüfen kannst du das mit genau dem curl-Aufruf von
oben: Bewegt sich der Wert mit, ist er wertlos.
Wie ich es mache
Der zuverlässigste Weg ist, den Hostnamen gar nicht aus dem Request zu nehmen. Du weißt doch, unter welchem Namen deine Anwendung läuft — schreib ihn in die Konfiguration:
<?php
// config.php
const BASE_URL = 'https://www.example.com';
?>
<?php
$url = BASE_URL . $_SERVER['REQUEST_URI'];
?>
Das ist unspektakulär, aber es kann nicht schiefgehen. Und in allen Fällen, in denen es wirklich darauf ankommt — E-Mails, Weiterleitungen, Zahlungs-Rückläufe — willst du ohnehin eine feste Adresse und nicht die, unter der der Besucher gerade zufällig hereingekommen ist.
Wenn du mehrere Domains bedienst und deshalb wirklich den Request auswerten musst, dann mit einer Positivliste:
<?php
function basisUrl(): string {
$erlaubt = [
'www.example.com' => 'https://www.example.com',
'shop.example.com' => 'https://shop.example.com',
];
$host = $_SERVER['HTTP_HOST'] ?? '';
return $erlaubt[$host] ?? 'https://www.example.com';
}
?>
Zusätzlich gehört das eine Schicht tiefer abgesichert. Bei nginx setzt du
server_name und stellst einen Default-Block davor, der alles Fremde abweist:
server {
listen 80 default_server;
return 444;
}
Damit kommt eine Anfrage mit fremdem Host: gar nicht erst bei PHP an.
Ein paar Details am Rande
REQUEST_URI enthält den Query-String schon. Das sieht man hier:
REQUEST_URI /url.php?a=1&b=zwei
QUERY_STRING a=1&b=zwei
Wer beides zusammensetzt, hat den Query-String doppelt. Wenn du den Pfad ohne Parameter brauchst:
<?php
$pfad = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
?>
SERVER_PORT ist nicht der Port, den der Besucher angesprochen hat. In
meinem Testaufbau steht dort 80, obwohl von außen Port 8083 verwendet wurde.
SERVER_PORT ist der Port, auf dem der Webserver lauscht. Der Port des
Besuchers steckt nur in HTTP_HOST. Man sieht gelegentlich Code, der
SERVER_PORT anhängt, wenn er nicht 80 oder 443 ist — hinter einem Proxy oder
in einem Container ergibt das eine falsche URL.
PHP sieht TLS nur, wenn der Webserver es meldet. Bei nginx mit PHP-FPM musste ich dafür
fastcgi_param HTTPS on;
in den HTTPS-Block schreiben. Ohne diese Zeile ist $_SERVER['HTTPS'] nicht
gesetzt, und der oben gezeigte Code baut eine http://-URL, obwohl die
Verbindung verschlüsselt ist. Bei Apache mit mod_ssl passiert das automatisch.
Und steht ein Proxy oder ein Load Balancer davor, der TLS terminiert, dann
kommt beim Webserver ohnehin nur HTTP an. Die Information steckt dann in
X-Forwarded-Proto — und für die gilt dasselbe wie für alle Client-Header:
Sie ist nur dann etwas wert, wenn du sicher weißt, dass sie von deinem eigenen
Proxy stammt.
Zusammenfassung
HTTP_HOSTist derHost:-Header und damit fälschbar.SERVER_NAMEist nur sicher, wenn der Webserver ihn wirklich setzt — bei Apache mit der VoreinstellungUseCanonicalName Offübernimmt er den gefälschten Wert.- Am besten die Basis-URL fest konfigurieren, besonders für E-Mails und Weiterleitungen.
- Bei mehreren Domains: Positivliste statt Übernahme.
REQUEST_URIenthält den Query-String bereits.SERVER_PORTist der Port des Webservers, nicht der des Besuchers.- Bei nginx + FPM braucht PHP
fastcgi_param HTTPS on;um von TLS zu wissen.
Hinweis zu Netcup (Werbung)
Der deutsche Hoster Netcup bietet unter anderem günstige und zugleich leistungsstarke Webhosting Pakete, KVM-basierte Root Server und dezidierte Server an. Mit unseren Gutscheincodes kannst du noch mehr Geld sparen (6€ bei deiner ersten Bestellung, 30% Rabatt auf alle KVM-basierten Root Server, ...).