The Host header is user input
A password reset link that points at somebody else server. A cached page served to every visitor with an attacker script in it. A rate limit that counts nothing. All three come from the same habit: reading a request header as though it described your infrastructure rather than something the caller typed.
Key takeaways
- Every header arrives with the request, so every header is attacker-controlled unless a proxy you operate overwrites it.
- The Host header is the usual entry point because frameworks expose it as though it were configuration.
- Password reset poisoning is the highest impact version: your system emails the victim a link pointing at the attacker.
- The same value cached becomes everybody problem rather than one request problem.
- The fix is an allowlist of hostnames you serve and a configured base URL, not sanitisation.
The habit behind all of it
When your application needs to build an absolute URL, the nearest value is usually the host from the incoming request. Frameworks make it easy and it reads like a fact about where the application is running.
It is not a fact. It is a line the caller sent, and they can send anything. The same is true of forwarding headers, the referring page, the client user agent, and any custom header your infrastructure adds, unless something you control replaces it on the way in.
The rule
A header is environment only if a component you operate sets it and overwrites whatever the caller supplied. If your proxy appends rather than replaces, the left-most value - the one everything downstream reads as the client - is chosen by the caller.
Password reset poisoning
The highest impact form, because it turns your own email into the delivery mechanism.
An attacker requests a reset for a victim address, supplying a Host header pointing at a server they control. The application generates a valid token and builds the link using that host. The victim receives a genuine email, from your domain, correctly signed, containing a link that sends the token somewhere else. If they click it, the attacker has a working reset token.
Nothing about the message looks wrong. It came from you because it did come from you, and that is what makes this worse than an ordinary phishing attempt.
When a cache is in front
On its own, a reflected header affects one request. Behind a cache, it can be stored and served to everybody who asks for the same page.
The mechanics are unglamorous: the cache decides what counts as the same request using a key, usually the method and the path. If a header outside that key changes the response, an attacker can send one request that poisons the entry and let the cache distribute it.
- A header reflected into the page body or into an absolute asset URL.
- A response that varies on a header the cache does not include in its key.
- Redirects built from the request host and then cached.
- Error pages that echo request detail and are cacheable.
The other headers people trust
| Header | Trusted for | What breaks |
|---|---|---|
| Host | Building absolute URLs and routing. | Reset poisoning, redirects to attacker infrastructure, cache poisoning. |
| X-Forwarded-For | Rate limiting, logging, geographic rules. | Spoofable unless the proxy replaces it. A limit keyed on it counts nothing. |
| X-Forwarded-Host and X-Forwarded-Proto | Reconstructing the original request behind a proxy. | The same problems as Host, in a header people forget to allowlist. |
| Referer | Access decisions and cross-site request checks. | Attacker controlled and often absent, so any rule built on it fails in both directions. |
| Custom internal headers | Marking a request as internal or already authenticated. | If it reaches the application from outside, anybody can set it. |
The last row is the one that turns into a full authentication bypass. A header meaning "this request came from inside" is a password written in the clear, and it works for anyone who guesses the name.
Fixing it
- Configure the hostnames you serve and reject anything else at the edge, before the application sees it.
- Build absolute URLs from configuration, never from the request. Email links especially.
- Have the proxy replace forwarding headers rather than appending to them, so the value downstream is one you set.
- Strip inbound copies of any header your infrastructure adds, so an outside caller cannot supply their own.
- Include in the cache key any header that can change the response, or make the response not vary on it.
- Never treat a header as proof of origin or authentication.
Sanitising the value is the wrong instinct. You are not trying to make an arbitrary hostname safe, you are trying to establish that it is one of yours, and a fixed list is the only thing that does that.
Testing for it
- Send a request with a Host header naming a domain you control and see whether the response reflects it or redirects there.
- Trigger a password reset with the same substitution and read the link in the email that arrives.
- Repeat with the forwarded-host and forwarded-proto variants, which are often missed by a Host allowlist.
- If a cache sits in front, check whether a reflected value persists into a subsequent clean request.
- For rate limiting, send the same requests with varying forwarded-for values and see whether the limit still applies.
The reset test is the one to run first. It is quick, it is unambiguous, and a positive result is an account takeover chain rather than a curiosity.