Host header injection: tested in nginx, and the default_server fix
The Host header tells the server which site you want. It comes from the client, so anything that copies it into links or redirects can be pointed at another domain. We reproduced that in nginx and fixed it.
1. The attack, with curl
nginx with one server block for example.test, building a password-reset link and an HTTPS redirect from $host:
$ curl -H 'Host: evil.example' http://SERVER/link
reset link: http://evil.example/reset?t=123
$ curl -sI -H 'Host: evil.example' http://SERVER/redir
HTTP/1.1 301 Moved Permanently
Location: https://evil.example/redir
nginx served the request although evil.example matched no server_name: with no explicit default, the first server block handles every unknown Host. If a reset email is built this way, the token goes to the attacker's domain when the victim clicks. If a cache stores the redirect, other visitors get it too.
2. The fix: a catch-all default_server
server {
listen 80 default_server;
server_name _;
return 444; # close the connection, send nothing
}
server {
listen 80;
server_name example.test;
...
}
After this, Host: evil.example and requests to the bare IP got an empty reply (curl exit 52), while Host: example.test worked and its redirect pointed to https://example.test/redir. Add the same catch-all for listen 443 ssl default_server (it needs a certificate; ssl_reject_handshake on; avoids that on nginx 1.19.4+, per nginx docs).
3. $host vs $http_host
For Host: Example.TEST:8080 nginx gave $host = example.test (lower-cased, port removed) and $http_host = Example.TEST:8080 (raw). Prefer $host, or better, a fixed name in redirects: return 301 https://example.test$request_uri;.
4. Other things we checked
- An HTTP/1.1 request with no Host header got
400 Bad Request: Host is mandatory in HTTP/1.1. - In the app, never build absolute URLs from the request's host; use a configured base URL. Frameworks have allow-lists for this (Django
ALLOWED_HOSTS, Railsconfig.hosts, LaravelTrustHosts). - Behind a proxy,
X-Forwarded-Hostis just as client-controlled unless the proxy overwrites it; the same applies to X-Forwarded-For (measured here).
Frequently asked questions
What is Host header injection?
Sending a fake Host header so the server builds links or redirects to another domain. In our nginx test, Host: evil.example produced a reset link and a 301 pointing at evil.example.
How do I prevent Host header injection in nginx?
Add a default_server that returns 444 and give real sites explicit server_names. In our test unknown hosts and the bare IP then got an empty reply.
What is the difference between $host and $http_host?
$host is lower-case without the port (example.test); $http_host is the raw header (Example.TEST:8080), as measured.
How do I send a custom Host header with curl?
curl -H 'Host: example.test' http://SERVER-IP/ . That's how we ran every test here; curl --resolve example.test:80:IP is the alternative that also works for HTTPS.
Written with AI assistance. Measured on 2026-10-01 with curl 8.14.1 and a raw socket against nginx 1.31.6 (nginx:alpine) in a throwaway container, first with a single server block, then with a default_server catch-all. Application-framework settings (Django, Rails, Laravel), ssl_reject_handshake, X-Forwarded-Host and curl --resolve are from their documentation, not this test. PlainHeaders is our extension; curl is free.