Web
Host header injection: a vulnerability that looks trivial but isn't
If an app trusts the Host or X-Forwarded-Host header, password reset links can point to a foreign domain. A real case, how to test for it, and the fix in Laravel and nginx.
· 7 min read

Host header injection is a vulnerability many developers overlook. There is no obvious bug in the code like SQL injection or XSS, and everything works as usual. But if the application blindly trusts the Host or X-Forwarded-Host header of a request, an attacker can point the links the application generates at their own domain.
This vulnerability was found during the expertise of our own product, a tax and finance SaaS platform. Below we show how it works, how to test for it, and how we closed it.
Where the problem comes from
Applications often build full (absolute) URLs: links in emails, redirects, canonical tags, file URLs. To do that, they take the domain name from the current request. The framework reads it from the Host header or, when the app sits behind a proxy, from X-Forwarded-Host.
The catch is that the client sends these headers itself. If the application trusts X-Forwarded-Host on the assumption that "the proxy must have added it," anyone can supply any domain they like.
A real case: the password reset link
In our case the application trusted all X-Forwarded-* headers. The attack scenario looks like this:
- The attacker enters the victim's email address in the password reset form.
- They add the header
X-Forwarded-Host: attacker.exampleto the request. - The application builds the reset link with that domain:
https://attacker.example/reset-password/<token>. - The victim receives a genuine email from the genuine site. If they click the link, the secret token goes to the attacker's server.
- The attacker uses the token to change the password on the real site.
The email comes from the official address and the text is correct; only the domain in the link is different. That is why this vulnerability is considered serious.
There can be other consequences too: links to a foreign domain stuck in cached pages (cache poisoning), or redirects to an outside site.
Why does this vulnerability go unnoticed for so long? Because in normal use Host always arrives correct, and everything works in tests. The problem only shows up when someone deliberately changes the header. And in apps behind a proxy, developers often set "trust everything" just to get the site working, then forget about it.
Every header the client sends is user input.
Hostis no exception.
How to test
The simplest test is to send a request with a foreign domain and see whether it shows up in the response:
curl -si https://your-domain.uz/login \
-H "X-Forwarded-Host: attacker.example" | grep -i attacker
curl -si https://your-domain.uz/login \
-H "Host: attacker.example" | grep -i attacker
Also:
- Submit the password reset form with
X-Forwarded-Hostand check the link in the email you receive (on your own test account). - Look at redirect responses with a
Locationheader. - Try alternative headers such as
X-Forwarded-Server,X-HostandForwarded.
If attacker.example appears in a response or email, the vulnerability is there.
The fix in Laravel
1. Limit proxy trust
Laravel's TrustProxies middleware defines which proxies and which headers to trust. That was exactly where our problem was: all headers were trusted. The fix is to trust only X-Forwarded-For, Proto and Port, and ignore X-Forwarded-Host.
In Laravel 11+, in bootstrap/app.php:
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: ['127.0.0.1'],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
);
})
Put only your real proxy address in at, not '*'.
2. Force the root URL in production
So that links are built from configuration rather than from the request, add this to AppServiceProvider::boot():
use Illuminate\Support\Facades\URL;
if ($this->app->isProduction()) {
URL::forceRootUrl(config('app.url'));
URL::forceScheme('https');
}
APP_URL in .env must contain the correct domain.
3. Allowed hosts
As an extra layer, enable the TrustHosts middleware, which accepts requests only for allowed domains:
$middleware->trustHosts(at: ['your-domain.uz']);
The fix in nginx
Alongside application-level protection, the web server should not pass requests with a foreign Host to the application at all.
default_server
nginx routes a request to the matching server block by server_name. If nothing matches, the request lands in the default_server — and if none is set, in the first block, which is often your main site. So add a separate catch-all block:
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on;
return 444;
}
server {
listen 443 ssl;
server_name your-domain.uz;
# ... main site
}
return 444 closes the connection without a response, and ssl_reject_handshake (nginx 1.19.4+) refuses the TLS handshake for unknown domains.
Headers passed to the upstream
If nginx proxies to the application, don't forward the client-supplied X-Forwarded-Host, and send Host with the server's value:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host "";
After the fix
Run the curl commands above again: a request with a foreign Host should be rejected by nginx, and X-Forwarded-Host should have no effect on generated links. Check the password reset email once more as well.
Summary
Host header injection is not visible in the code — it lives in trust settings. The fix is short: limit proxy trust, force the root URL, and add a default_server in nginx. But you have to know about it and test for it in advance.
Want to make sure your site doesn't trust foreign headers? Request a security scan — the Claude AI agent and our specialist will review your configuration and help close any holes we find.


