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-action zeigt 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_HOST ist der Host:-Header und damit fälschbar.
  • SERVER_NAME ist nur sicher, wenn der Webserver ihn wirklich setzt — bei Apache mit der Voreinstellung UseCanonicalName 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_URI enthält den Query-String bereits.
  • SERVER_PORT ist 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, ...).