Cyber Security

Host Header Validation in PHP - Treat the Request Authority as Input, Not Configuration

Host Header Validation in PHP - Treat the Request Authority as Input, Not Configuration

A small PHP application sometimes needs its own public address. It may build a password-reset link, produce a canonical URL, or redirect a visitor to the HTTPS version of a page. The convenient shortcut is to prepend a scheme to $_SERVER['HTTP_HOST']. But who chose that host: the application operator, the reverse proxy, or the client making this particular request?

That distinction matters because a request header can quietly become application configuration. The Host field has a legitimate protocol job, but “present and syntactically valid” does not mean “authorized for this application.” A modest defense is to keep three decisions separate: which virtual host receives the request, which authorities the application accepts, and which configured origin the application uses when it creates public links.

What Host identifies

An HTTP URL contains an authority: normally a hostname and, when needed, a port. In HTTP/1.1, the Host field carries that information for the common origin-form request target. HTTP/2 and HTTP/3 normally express the same role with the :authority pseudo-header. RFC 9110 section 7.2 describes this authority information as part of routing a request and identifying its target resource.

This is what lets several sites share one address. A web server can receive traffic on one socket and use the requested name to choose a virtual host. The Nginx request-processing documentation explains that Nginx first narrows the choice by listen address and port, then compares the request name with server_name. If no name matches, the default server for that listen socket handles the request.

HTTP/1.1 also imposes basic message rules. RFC 9112 section 3.2 requires a client to send Host, and requires a server to return 400 Bad Request when the field is missing, appears more than once, or has an invalid value. Those checks answer a syntax and framing question. They do not answer the deployment question: is preview.example.com, example.com:8443, or an unrelated valid domain a name this application intends to serve?

How a routing value becomes a security problem

The risk starts when code treats request authority as a trusted source of truth. PHP exposes request headers through server variables; the PHP manual for $_SERVER notes that entries are created by the web server and that request headers receive HTTP_-prefixed names. The exact variables depend on the server interface, but HTTP_HOST should still be treated as request input, not as a deployment secret.

Consider a reset endpoint that builds this link:

$resetUrl = 'https://' . $_SERVER['HTTP_HOST']
    . '/reset?token=' . rawurlencode($token);

If the surrounding infrastructure accepts an unexpected authority and the application sends that URL by email, the recipient may receive a valid token inside a link pointing to the wrong origin. This pattern is commonly called password-reset poisoning. It is not automatically exploitable in every deployment: routing, proxy rewriting, email behavior, token lifetime, and other controls all matter. Still, the application has unnecessarily let one request choose where a sensitive link points.

The same trust mistake can influence absolute redirects, canonical links, cache entries, or back-end routing. PortSwigger's Host header guidance documents these possible consequences and recommends avoiding request-derived hosts for absolute URLs where possible. The important lesson is narrower than “the Host header is dangerous”: authority is useful input for routing, but it is not automatically safe configuration for every downstream decision.

Separate three decisions

1. Let the edge route only names it understands

Configure an explicit default virtual server that does not fall through to the application. A minimal Nginx shape is:

server {
    listen 80 default_server;
    server_name _;
    return 400;
}

server {
    listen 80;
    server_name example.com www.example.com;

    # Normal application locations go here.
}

The exact TLS and IPv6 listeners must be handled separately in a real configuration. The underscore is only a conventional non-domain name here; default_server determines the fallback for the listen socket. Returning 400 is also a local policy choice, not a claim that every unmatched but syntactically valid authority violates HTTP.

2. Make the application enforce its own accepted set

Edge filtering is valuable, but the application may also be reachable through another listener, a development server, or a changed proxy route. For a small deployment with a fixed set of ASCII domain names, an exact comparison is easier to audit than a permissive parser:

<?php

$allowedAuthorities = [
    'example.com',
    'www.example.com',
];

$authority = strtolower($_SERVER['HTTP_HOST'] ?? '');

if (!in_array($authority, $allowedAuthorities, true)) {
    http_response_code(400);
    header('Content-Type: text/plain; charset=utf-8');
    exit('Unrecognized request authority.');
}

This guard intentionally does little normalization. It accepts case variation because DNS hostnames are case-insensitive, then rejects everything not explicitly listed. An unexpected port, trailing dot, suffix such as example.com.attacker.test, whitespace, IP literal, or development alias fails unless the deployment deliberately adds that exact form.

That narrow behavior is not a universal authority parser. Internationalized domain names, IPv6 literals, dynamic tenant domains, and applications that intentionally serve several ports need a documented canonicalization and onboarding policy. A framework may already provide one. For example, Django's ALLOWED_HOSTS documentation specifies its own matching, port, wildcard, and trailing-dot behavior. The PHP example should not replace a framework's reviewed host-validation mechanism.

3. Generate public URLs from configuration

Validation says whether a request may continue. Canonical URL generation answers a different question. If the site has one public origin, configure it once:

$publicOrigin = rtrim((string) getenv('APP_PUBLIC_ORIGIN'), '/');

if ($publicOrigin !== 'https://example.com') {
    throw new RuntimeException('APP_PUBLIC_ORIGIN is not configured safely.');
}

$resetUrl = $publicOrigin
    . '/reset?token=' . rawurlencode($token);

The literal check is illustrative for a single fixed production origin. A real application may validate configuration at startup instead. The key property is that an incoming request does not choose the origin of an email, webhook callback, canonical tag, or other public absolute URL. Relative links are even simpler when the consumer does not require an absolute URL.

A reverse proxy changes the source, not the need for trust

A proxy may receive public.example and connect to an upstream named php.internal. It may preserve the original authority in Host, or put it in Forwarded or X-Forwarded-Host. None of those names becomes trustworthy merely because it contains “forwarded.”

The application should consume forwarded authority only when the direct peer is a known proxy and that proxy removes any client-supplied copy before writing its own value. Otherwise, a client can provide the supposed proxy metadata itself. This is the same boundary principle used for forwarded client addresses, but the data and consequences differ: here the decision concerns which public authority the request represents.

There is no single header rule that fits every proxy chain. Document which component owns the public authority, how each hop rewrites it, and which values the final application accepts. If the application needs only one canonical public origin, a fixed configuration often avoids the question for generated links altogether.

Test the rejection path, not only the happy path

On a system you own or are authorized to assess, send requests with the expected authority and with deliberately unexpected variants. Check the edge and the application separately. Useful cases include:

  • the production hostname with ordinary case variation;
  • an unrelated hostname;
  • a suffix trick such as example.com.attacker.test;
  • an unconfigured port or trailing dot;
  • X-Forwarded-Host supplied directly by a client;
  • redirects, reset emails, canonical tags, and any response containing an absolute URL.

A rejected authority should not be redirected to a URL built from that same authority. That merely reflects untrusted input into another response. Also inspect cache behavior and logs, while avoiding the mistake of storing sensitive reset tokens in diagnostic output.

What this defense does not prove

An allowlist does not prove that DNS points where you expect, that a TLS certificate is valid, that a proxy is correctly isolated, or that caches key responses safely. It does not repair weak reset tokens or unsafe output encoding. Conversely, correct TLS does not necessarily stop an application from generating an absolute link from a manipulated request value after traffic reaches a permitted endpoint.

Multi-tenant platforms also cannot replace a domain-ownership process with a broad wildcard and call the problem solved. They need a trusted mapping from tenant to verified domain, plus rules for activation, removal, and canonicalization. The exact-list example is useful precisely because its scope is smaller.

Conclusion

The Host field is not an accidental HTTP detail. It helps a server identify the authority a client wants to reach. The mistake is asking that request value to serve another role: permanent application configuration.

For a small PHP site, a defensible arrangement is straightforward: reject unknown names at the edge, enforce a narrow accepted set in the application, trust forwarded authority only across an explicit proxy boundary, and build public absolute URLs from configuration. None of those layers is magical. Together, they make the important question visible: which component is allowed to name this application?

References