Host header injection means a web server or application trusts the client-supplied HTTP Host header (or X-Forwarded-Host) and uses it to build absolute URLs in links, redirects or emails. Fix it by generating URLs from a configured canonical hostname, rejecting unknown hosts with a catch-all virtual host, and allowlisting hosts in the application.
What the scanner is actually detecting
Two web scanners report this flaw under slightly different names. Both map it to CWE-20 and rate it Medium.
| Scanner | Finding title | ID | Vendor severity |
|---|---|---|---|
| Tenable Web App Scanning | Host Header Injection | 98623 (Injection family) | Medium (CVSS v3 4.3) |
| Acunetix | Host header attack | not listed on the public page | Medium |
Tenable’s description says developers often build link URIs from the Host header, so an attacker who sends a domain under their control can poison a web cache or a password reset email. Its solution text says the application should not trust Host or X-Forwarded-Host and should use a secure SERVER_NAME instead. Acunetix describes the same root cause and adds two server-side remedies: a dummy virtual host that catches unrecognized Host values, and a non-wildcard server name (on Apache, together with UseCanonicalName).
The finding’s evidence normally shows the hostname the scanner injected and where it came back, such as a Location header, an absolute link or a script URL. This is a configuration and code issue, so no patch closes it.
How serious is host header injection?
A browser sets Host from the URL the user visits, so an attacker cannot make a victim’s browser send a forged value. A forged Host reflected only in the attacker’s own response is harmless by itself. The risk comes from places where one forged request affects someone else:
- Password reset poisoning: the attacker requests a reset for the victim’s account with a forged Host. If the reset link is built from that header, the victim gets a genuine email whose link points to the attacker’s domain, and clicking it hands over the reset token.
- Web cache poisoning: if a cache or CDN stores a response built from a header it does not include in the cache key, such as
X-Forwarded-Host, other visitors can receive links or script URLs pointing at the attacker. - Routing abuse: PortSwigger’s research also describes routing-based SSRF and access to internal-only virtual hosts when a load balancer or reverse proxy routes on the Host value.
Tenable’s CVSS v3 vector (user interaction required, low confidentiality impact) fits: Medium, not critical. Raise the priority for sites with password reset, a shared cache, or a reverse proxy in front of internal applications.
How to confirm it on the host
Only test systems you are authorized to test. Use a canary name under the reserved .example TLD. When you pass a custom Host header, curl still uses the URL’s hostname for TLS SNI, which reproduces the mismatch a scanner creates:
# Forged Host reflected in a redirect? (a directory without its trailing slash
# triggers the web server's own redirect)
curl -s -D - -o /dev/null -H 'Host: canary.example' https://www.example.com/images | grep -i '^location:'
# Forged Host reflected in links or script URLs in the page body?
curl -s -H 'Host: canary.example' https://www.example.com/ | grep -o 'canary.example[^"]*' | sort -u
# X-Forwarded-Host override, real Host left unchanged
curl -s -H 'X-Forwarded-Host: canary.example' https://www.example.com/ | grep -c 'canary.example'
Any canary.example in the output confirms reflection. On Windows, call curl.exe explicitly and use -o NUL. For the high-impact case, submit the password reset form for a test account you own through an intercepting proxy such as Burp Suite or OWASP ZAP, change the Host header, and read the link in the email.
Then look at how the server decides which site answers:
# Apache: show vhosts and the default server for each address:port
apachectl -S
# nginx: dump the effective configuration
nginx -T 2>/dev/null | grep -nE 'listen|server_name|default_server'
# IIS: list sites and bindings; a binding ending in ":" has no host name
%windir%system32inetsrvappcmd.exe list site
How to fix host header injection
Do both halves: the application builds absolute URLs from configuration, and the server stack refuses Host values it does not serve. In any framework, replace code that builds URLs from $_SERVER['HTTP_HOST'], req.headers.host or similar with a base URL from configuration, and use relative URLs where an absolute one is not needed.
Apache HTTP Server
Apache sends a request whose Host matches no ServerName or ServerAlias to the first listed virtual host for that address and port. Make that first vhost a catch-all that denies everything. In the real vhosts, set UseCanonicalName On so Apache’s self-referential URLs (such as the trailing-slash redirect from mod_dir) and the SERVER_NAME variable come from ServerName instead of the client.
# Must be the first vhost loaded for *:80, for example 000-catchall.conf
<VirtualHost *:80>
ServerName catchall.invalid
UseCanonicalName On
<Location "/">
Require all denied
</Location>
</VirtualHost>
<VirtualHost *:80>
ServerName www.example.com
ServerAlias example.com
UseCanonicalName On
DocumentRoot "/var/www/example"
</VirtualHost>
Repeat the pattern for *:443 (the catch-all needs its own SSL directives and certificate). Apply with apachectl configtest && apachectl graceful. If Apache reverse-proxies to an application, mod_proxy appends to any X-Forwarded-Host the client already sent, so the backend must not trust that header blindly.
Nginx
nginx routes a request whose Host matches no server_name to the default server for that port, which is the first server block unless one is marked default_server. Add an explicit catch-all that closes the connection with nginx’s non-standard code 444, and list only real names in each site:
server {
listen 80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on; # nginx 1.19.4 or later
return 444;
}
server {
listen 443 ssl;
server_name www.example.com example.com;
# ssl_certificate and ssl_certificate_key here
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
}
}
ssl_reject_handshake refuses TLS handshakes for names you do not serve, and return 444 catches a valid SNI sent with a forged Host (what the curl test does). Setting X-Forwarded-Host explicitly replaces any client value. Apply with nginx -t && nginx -s reload.
IIS
A binding with an empty host name (for example *:80:) accepts every Host value. Give each site explicit host-name bindings and remove the blank one, including on the Default Web Site. IIS then rejects unknown hosts with HTTP 400 (Invalid Hostname) before the request reaches your application.
%windir%system32inetsrvappcmd.exe add backup "before-host-bindings"
%windir%system32inetsrvappcmd.exe set site /site.name:"Contoso" /+bindings.[protocol='http',bindingInformation='*:80:www.contoso.com']
%windir%system32inetsrvappcmd.exe set site /site.name:"Contoso" /-bindings.[protocol='http',bindingInformation='*:80:']
For HTTPS, use host-name bindings with Server Name Indication (sslFlags value 1), such as *:443:www.contoso.com.
Django
# settings.py
ALLOWED_HOSTS = ["www.example.com", "example.com"]
USE_X_FORWARDED_HOST = False # the default; enable only behind a proxy you control that sets it
Django validates the Host header in request.get_host() and answers a non-matching host with 400 Bad Request (a DisallowedHost exception). Two traps: ALLOWED_HOSTS = ["*"] turns the check off, and code that reads request.META["HTTP_HOST"] directly bypasses it.
ASP.NET Core
// appsettings.Production.json
{
"AllowedHosts": "www.example.com;example.com"
}
A semicolon-delimited AllowedHosts list enables the host filtering middleware, which returns 400 for any other host. The value "*", often left in place from a new project, allows every non-empty host. Behind a proxy, enable ForwardedHeaders.XForwardedHost only if you also set ForwardedHeadersOptions.AllowedHosts.
How to verify the fix and rescan
Rerun the curl tests. Expected results: an empty reply from nginx (curl reports 000 and “Empty reply from server”), 403 from the Apache catch-all, or 400 from IIS, Django or ASP.NET Core, and no canary.example anywhere. Confirm the real hostname still works:
curl -s -o /dev/null -w '%{http_code}n' https://www.example.com/
curl -s -o /dev/null -w '%{http_code}n' -H 'Host: canary.example' https://www.example.com/
Repeat the password reset test and confirm the email link uses your domain. Then rescan the same URL with the same scanner policy. If a WAF or CDN sits in front, test through it and directly against the origin, because a WAF can change what a web scan sees and hide a problem that still exists behind it.
What can break and how to roll back
- Health checks by IP: load balancer probes and uptime checks often send an IP address as Host. Allowlist it or point the probe at a named site.
- Forgotten aliases: apex versus www, legacy domains, staging names and
localhost. Anything not listed stops working. - Proxies that rewrite Host: if the proxy sends an internal backend name, the backend allowlist must include it, or the proxy must preserve the public Host.
- Certificate renewal: ACME HTTP-01 challenges use the real domain as Host, so they keep working as long as that name is served on port 80.
Rollback is a configuration revert: copy vhost and server block files before editing, use appcmd add backup on IIS (restore with appcmd restore backup "before-host-bindings"), then reload.
Common false positive reasons
- Reflection with no consumer: the forged Host appears only in the scanner’s own response, nothing caches it and no email is built from it. Accurate but low impact; document it, because a later cache or reset feature makes it exploitable.
- Scanned by IP address: the scan reached a default site (the IIS Default Web Site or a distribution’s default Apache page), not the application you fixed.
- Origin tested directly: the edge proxy overwrites
X-Forwarded-Host, but the scanner bypassed it. Still fix an origin reachable from the internet. - Stale results: the scan ran before the reload, or a CDN served a cached response.
FAQ
Is host header injection the same as password reset poisoning?
No. Host header injection is the underlying flaw. Password reset poisoning, cache poisoning and routing attacks are ways to exploit it.
Does UseCanonicalName On fix it on its own?
No. It controls Apache’s self-referential URLs and SERVER_NAME, but an application reading the Host header still sees the client value. Add the catch-all vhost and an application allowlist too.
Why return 444 in nginx instead of 400?
444 is nginx-specific: it closes the connection without a response. A 400 or 403 also works.
Would a code scan have caught this?
Tenable WAS and Acunetix find it dynamically from outside. A code review finds the exact lines that read the Host header. See how SAST, DAST and SCA differ.
Tracking this finding across many hosts
Host header injection usually shows up on many virtual hosts at once. SITEY, a self-hosted vulnerability management platform, imports findings from 16 scanners and merges duplicates within each scanner, not across scanners, so records from two different scanners for the same site stay separate. For Acunetix findings it can run a per-finding retest to confirm the fix before the item is closed.