PHP: How do you determine the current URL?
PHP has no ready-made variable holding the current URL. You assemble it from
several parts of $_SERVER. The snippet for it is everywhere — and it contains
a security hole you cannot see just by looking at it.
The widespread solution
<?php
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
? 'https' : 'http';
$url = $scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
?>
It works. I tested it behind nginx with PHP-FPM, once over HTTP and once over HTTPS.
Over HTTP:
HTTPS (not set)
HTTP_HOST localhost:8083
SERVER_NAME
SERVER_PORT 80
REQUEST_URI /url.php
QUERY_STRING
DERIVED http://localhost:8083/url.php
Over HTTPS:
HTTPS on
HTTP_HOST localhost:8443
SERVER_PORT 443
REQUEST_URI /url.php?a=1
DERIVED https://localhost:8443/url.php?a=1
Looks good. And now the same request once more, with a single extra parameter
to curl:
user@pc:~$ curl -H 'Host: boese.example' http://localhost:8083/url.php
HTTP_HOST boese.example
DERIVED http://boese.example/url.php
Why that is a problem
HTTP_HOST is nothing but the request’s Host: header. As with every HTTP
header, the client decides what it contains. PHP only copies it into
$_SERVER.
As long as you only write the URL to a log, that is annoying but harmless. It gets interesting in these cases:
- Password resets. You build the link from the derived URL and send it by
email. An attacker requests a reset for somebody else’s account, sets the
Host:header to their own server — and gets the token delivered to their door as soon as the victim clicks. - Redirects.
header('Location: ' . $url)sends the visitor wherever the attacker would like them to go. - Cache poisoning. With a cache in front, the manipulated URL ends up in pages that other visitors receive too.
- Absolute URLs in the HTML. Every
<link>, every<script src>, every formactionsuddenly points somewhere else.
What speaks against SERVER_NAME
SERVER_NAME is often suggested as the way out. It is the value configured in
the web server, and the client cannot set that. Except:
SERVER_NAME
In my nginx setup it is empty. The configuration sets no server_name, and
then PHP simply gets nothing.
So I ran the same test against Apache, using the official php:8.5-apache
image and configuring nothing at all. Called normally:
HTTP_HOST localhost:8084
SERVER_NAME localhost
SERVER_PORT 8084
And with the same forged Host: header as above:
HTTP_HOST boese.example
SERVER_NAME boese.example <- follows along
SERVER_PORT 80
SERVER_NAME picks up the forged value. The reason is the UseCanonicalName
directive, which defaults to Off — and then Apache fills SERVER_NAME from
the Host: header. So the advice “use SERVER_NAME instead of HTTP_HOST”
achieves precisely nothing in this configuration.
SERVER_PORT is worth noting too: in the forged case it reads 80 instead of
8084, because my Host: header contained no port. So that value can be
influenced as well.
SERVER_NAME is therefore only safe if your web server is explicitly given it
— with Apache that means ServerName and UseCanonicalName On. You can
check it with exactly the curl call above: if the value follows along, it is
worthless.
How I do it
The most reliable route is not to take the host name from the request at all. You know the name your application runs under — put it in the configuration:
<?php
// config.php
const BASE_URL = 'https://www.example.com';
?>
<?php
$url = BASE_URL . $_SERVER['REQUEST_URI'];
?>
That is unspectacular, but it cannot go wrong. And in every case where it really matters — emails, redirects, payment callbacks — you want a fixed address anyway, not the one the visitor happened to come in through.
If you serve several domains and therefore genuinely have to evaluate the request, do it with an allowlist:
<?php
function baseUrl(): string {
$allowed = [
'www.example.com' => 'https://www.example.com',
'shop.example.com' => 'https://shop.example.com',
];
$host = $_SERVER['HTTP_HOST'] ?? '';
return $allowed[$host] ?? 'https://www.example.com';
}
?>
On top of that it belongs secured one layer down. With nginx you set
server_name and put a default block in front that rejects anything foreign:
server {
listen 80 default_server;
return 444;
}
That way a request with a foreign Host: never reaches PHP in the first place.
A few details on the side
REQUEST_URI already contains the query string. You can see it here:
REQUEST_URI /url.php?a=1&b=zwei
QUERY_STRING a=1&b=zwei
Anyone who concatenates both ends up with the query string twice. If you need the path without parameters:
<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
?>
SERVER_PORT is not the port the visitor connected to. In my setup it
reads 80, even though port 8083 was used from outside. SERVER_PORT is the
port the web server listens on. The visitor’s port is only in HTTP_HOST. You
occasionally see code that appends SERVER_PORT when it is not 80 or 443 —
behind a proxy or inside a container that produces a wrong URL.
PHP only sees TLS if the web server reports it. With nginx and PHP-FPM I had to put
fastcgi_param HTTPS on;
into the HTTPS block for that. Without that line $_SERVER['HTTPS'] is not
set, and the code shown above builds an http:// URL even though the
connection is encrypted. With Apache and mod_ssl it happens automatically.
And if a proxy or load balancer terminating TLS sits in front, only HTTP
reaches the web server anyway. The information is then in X-Forwarded-Proto —
and for that the same applies as for all client headers: it is only worth
something if you know for certain it came from your own proxy.
Summary
HTTP_HOSTis theHost:header and therefore forgeable.SERVER_NAMEis only safe if the web server really sets it — with Apache’s defaultUseCanonicalName Offit picks up the forged value.- Best to configure the base URL as a fixed value, especially for emails and redirects.
- With several domains: an allowlist rather than taking the value over.
REQUEST_URIalready contains the query string.SERVER_PORTis the web server’s port, not the visitor’s.- With nginx + FPM, PHP needs
fastcgi_param HTTPS on;to know about TLS.
About Netcup (advertisement)
The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).