Host Header Injection: Password Reset Poisoning, Cache Deception, and SSRF

The HTTP Host header controls where the server thinks the request arrived from — and most web frameworks trust it implicitly. Host header injection is the entry point for password reset link poisoning, web cache deception, and server-side request forgery attacks. Here's how each variant works and how to fix them.

The HTTP Host header tells a web server the domain name used to make the request. When a client sends Host: example.com, the server uses that value to select the right virtual host, construct absolute URLs in redirects, and generate password reset links. The problem: most web frameworks read this header from the incoming request and trust it without verification. An attacker who controls the Host header controls what the server thinks it is — and that has serious downstream consequences.

Host header injection is underappreciated compared to XSS or SQL injection, but it’s consistently found in bug bounty submissions and penetration tests. Three attack primitives dominate: password reset link poisoning, web cache poisoning, and server-side request forgery (SSRF) through backend routing.

The most dangerous and most common Host header attack. Many web applications construct password reset links using the incoming Host header:

# Vulnerable Flask example
from flask import request, url_for

def send_password_reset(user):
    # request.host comes from the untrusted Host header
    reset_link = f"https://{request.host}/reset?token={generate_token(user)}"
    send_email(user.email, f"Reset your password: {reset_link}")

An attacker who can send a password reset request for a victim’s account while controlling the Host header will poison the reset email:

POST /forgot-password HTTP/1.1
Host: attacker.com
Content-Type: application/x-www-form-urlencoded

[email protected]

The victim receives an email from the legitimate domain with a link to https://attacker.com/reset?token=abc123. When they click it, the attacker’s server captures the token. On many applications, that token is sufficient to reset the account password.

Fix: Never use the Host header to construct URLs. Use an explicit base URL from server configuration:

# Safe Python (Flask) — base URL from config
from flask import current_app

def send_password_reset(user):
    base_url = current_app.config['BASE_URL']  # e.g. "https://example.com"
    reset_link = f"{base_url}/reset?token={generate_token(user)}"
    send_email(user.email, f"Reset your password: {reset_link}")
// Safe Node.js/Express — base URL from environment variable
const BASE_URL = process.env.BASE_URL; // never from req.headers.host

function sendPasswordReset(user, token) {
  const resetLink = `${BASE_URL}/reset?token=${token}`;
  mailer.send({ to: user.email, body: `Reset: ${resetLink}` });
}
// Safe Java (Spring Boot) — configured property
@Value("${app.base-url}")
private String baseUrl;

public void sendPasswordReset(User user, String token) {
    String resetLink = baseUrl + "/reset?token=" + token;
    emailService.send(user.getEmail(), "Reset: " + resetLink);
}

Attack 2: Web Cache Poisoning via the Host Header

Reverse proxies and CDNs use the Host header as a cache key component by default. If a proxy caches a response generated using a malicious Host value, subsequent legitimate users requesting the same path may receive the poisoned response.

The attack works when:

  1. The proxy includes Host in the cache key
  2. The application reflects Host in the response (common in redirect URLs, canonical links, or Open Graph metadata)
  3. The poisoned response is served to other users
GET / HTTP/1.1
Host: attacker.com
X-Forwarded-Host: attacker.com

# If the app uses X-Forwarded-Host to build canonical URLs:
# <link rel="canonical" href="https://attacker.com/" />
# This response gets cached and served to legitimate users

Variations use headers like X-Forwarded-Host, X-Original-Host, or X-Rewrite-URL when the primary Host header is validated but these secondary headers are not.

Fix: Explicitly validate the Host header against an allowlist before using it in any output:

# Django — use ALLOWED_HOSTS (enforced by framework)
# settings.py
ALLOWED_HOSTS = ['example.com', 'www.example.com']
# Django raises SuspiciousOperation for requests with unlisted Host values

# If you must read Host manually, validate it:
ALLOWED_HOSTS = {'example.com', 'www.example.com'}

def validate_host(request):
    host = request.META.get('HTTP_HOST', '').lower().split(':')[0]
    if host not in ALLOWED_HOSTS:
        raise ValueError(f"Invalid Host header: {host}")
    return host
// Go (net/http) — validate Host header explicitly
var allowedHosts = map[string]bool{
    "example.com":     true,
    "www.example.com": true,
}

func validateHost(r *http.Request) error {
    host := r.Host
    if idx := strings.Index(host, ":"); idx != -1 {
        host = host[:idx]
    }
    if !allowedHosts[host] {
        return fmt.Errorf("invalid host: %s", host)
    }
    return nil
}

Attack 3: SSRF via Host Header in Routing

In microservice architectures, a reverse proxy or API gateway often routes requests to backend services based on the Host header. If the routing logic is insufficiently validated, injecting a Host value that resolves to an internal service bypasses network perimeter controls.

GET /api/data HTTP/1.1
Host: internal-service.local

# If the proxy routes based on Host:
# → Request forwarded to http://internal-service.local/api/data
# → Bypasses external authentication

More subtle: some load balancers use X-Forwarded-Host or X-Original-URL for routing decisions. If these are trusted from external requests, an attacker can reach internal services that are not meant to be externally accessible.

Fix:

  • Configure the proxy to validate Host values against an allowlist before routing
  • Strip or block X-Forwarded-Host, X-Original-URL, and X-Rewrite-URL from inbound external requests at the edge
  • Use internal service discovery (DNS-SD, Consul) rather than Host-header-based routing where possible

Testing for Host Header Injection

Manual testing:

# Test 1: Basic Host injection — does the app reflect the Host value?
curl -sk "https://example.com/forgot-password" \
  -H "Host: attacker.com" \
  -d "[email protected]" -v 2>&1 | grep -i "attacker"

# Test 2: X-Forwarded-Host override
curl -sk "https://example.com/" \
  -H "X-Forwarded-Host: attacker.com" \
  -v 2>&1 | grep -i "attacker"

# Test 3: Port injection in Host header
curl -sk "https://example.com/forgot-password" \
  -H "Host: example.com:evil.com" \
  -d "[email protected]" -v

Automated: Burp Suite Pro’s active scanner detects Host header injection as a first-class finding. In manual testing, use Burp Collaborator or interactsh as your out-of-band collector for injected Host values that don’t reflect visibly in the response.

Security Configuration Checklist

  • Base URLs constructed from server configuration, never from request.host
  • Framework-level Host header validation enabled (ALLOWED_HOSTS in Django, equivalent in other frameworks)
  • X-Forwarded-Host, X-Original-Host, X-Rewrite-URL stripped at the reverse proxy/load balancer for external traffic
  • Cache keys validated to prevent Host-based cache poisoning (review your CDN/proxy configuration)
  • Password reset email generation tested with modified Host headers in CI/CD security tests
  • Redirect URLs constructed from configuration constants, validated against an allowlist before sending

Host header injection is one of those vulnerabilities that appears harmless in isolation — the attacker just changes a header — but its exploitation consequences are severe when it intersects with password reset flows. Given that the fix is a single configuration value or an explicit allowlist check, it should be treated as a straightforward remediation rather than an architectural challenge.