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 form action suddenly 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_HOST is the Host: header and therefore forgeable.
  • SERVER_NAME is only safe if the web server really sets it — with Apache’s default UseCanonicalName Off it 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_URI already contains the query string.
  • SERVER_PORT is 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, ...).