These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityHost Header Poisoning

Host Header Poisoning

$_SERVER['HTTP_HOST'] and $_SERVER['SERVER_NAME'] are derived from the HTTP Host header (and on some webserver configs from X-Forwarded-Host). Both come from the client. Any place those values land in a URL, redirect, email, cache key, or CORS origin is a vulnerability.

Why

The canonical exploit is password reset:

$reset_link = "https://" . $_SERVER['HTTP_HOST'] . "/reset?token=$token";
mail($user_email, "Reset your password", $reset_link);

Attacker POST /forgot with Host: attacker.com, victim receives an email with https://attacker.com/reset?token=…, clicks it, attacker captures the token, takes over the account.

Other sinks of the same primitive:

  • OAuth redirect_uri defaults derived from Host
  • Absolute URLs in transactional emails (image embeds, “click here” CTAs, unsubscribe links)
  • Cache keys → web cache poisoning (response with attacker host served to other users)
  • CORS — Access-Control-Allow-Origin: echoed back from request origin / host
  • Canonical / OG meta tags → SEO poisoning, social-share hijack
  • Routing decisions for multi-tenant apps

Search patterns

rg -n 'HTTP_HOST|SERVER_NAME|HTTP_X_FORWARDED_HOST' --type=php
 
# Common framework helpers that read Host under the hood
rg -n '->getHost\(\)|->getSchemeAndHttpHost\(\)|url\(|route\(|asset\(' --type=php
 
# Mail and URL construction
rg -n 'mail\(|Mail::|->send\(' --type=php

Test inputs

Single Host swap:

GET /forgot HTTP/1.1
Host: attacker.com

Forwarded-Host header (works if the app trusts it):

GET /forgot HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com

Two Host headers (some frontends pass the second through):

GET /forgot HTTP/1.1
Host: target.com
Host: attacker.com

Host header injection (CRLF in Host):

Host: target.com\r\nX-Forwarded-Host: attacker.com

Same-domain subdomain abuse:

  • Host: target.com.attacker.com — naive .target.com substring matchers miss this
  • Host: attacker.com#@target.com (URL parsers vary)
  • Host: target.com:80@attacker.com (userinfo confusion)

Other relevant headers commonly trusted:

  • X-Forwarded-Host
  • X-Original-URL / X-Rewrite-URL (Symfony, IIS rewrite)
  • X-Forwarded-Server

Audit focus

For every read of HTTP_HOST / SERVER_NAME / HTTP_X_FORWARDED_HOST:

  1. Where does the value land?
    • URL in an email → password reset takeover
    • OAuth callback / SSO redirect → token theft
    • Cache key → cache poisoning across users
    • CORS response → cross-origin data leakage
    • HTML <a> href / OG tag → phishing
  2. Is the host validated against an allowlist?
  3. Is the value used pre-auth? Password reset, signup confirmation, MFA reset — these are the high-value pre-auth sinks.
  4. Webserver config — does Apache / Nginx pass through unexpected Host values, or does it reject them at the edge?
  5. Multi-tenant routing — if Host selects the tenant, is the value normalised before being used as a cache / session key?
⚠️

Frameworks often expose Host through helper methods (request->getHost, url(), route()) that look harmless. The footgun is exactly the same — they read HTTP_HOST under the hood. Look at the framework’s configuration: Symfony has trusted_hosts; Laravel does not (you must validate manually).

Fix

Hardcode the canonical host in app config and reject unknown values at the boundary:

$allowed_hosts = ['target.com', 'www.target.com'];
$host = $_SERVER['HTTP_HOST'] ?? '';
if (!in_array($host, $allowed_hosts, true)) {
  http_response_code(400);
  exit;
}

Better: don’t trust Host at all for URL construction. Use a configured APP_URL env var:

$base = rtrim(getenv('APP_URL'), '/');
$reset_link = "$base/reset?token=$token";

At the webserver:

server {
  server_name target.com www.target.com;
  if ($host !~* ^(target\.com|www\.target\.com)$) { return 444; }

}