Host header injection: how it works, how testers prove it, how to fix it
Host header injection happens when an application trusts the HTTP Host header, or an override such as X-Forwarded-Host, which the client controls. Attackers use it to poison password reset links, caches and routing. Fix it by building absolute URLs from fixed configuration, allowing only known hostnames, and removing override headers at the edge.
Host header injection is a bug in which an application trusts the HTTP Host header, or a header that overrides it, to build links, route requests or make security decisions. The client controls both. PortSwigger says the main cause is the flawed assumption that the header is not user controllable.
Only test systems you own or have written authorization to test. Every example here uses a fictional app on example.com.
What is host header injection?#
The Host header names the domain the client wants to reach. It is mandatory as of HTTP/1.1. It lets one IP address serve many sites, through virtual hosting or through intermediaries such as load balancers, reverse proxies and CDNs. PortSwigger calls an attack "Host header injection" when a payload goes straight into that header.
The bug has three common forms. The application writes the header into a link, such as a password reset URL. A cache stores a response that contains the header value. Or a front-end server uses the header to choose a back end, so a crafted value reaches a host that was never meant to be public.
Do not mix it up with HTTP header injection. That bug, also called CRLF injection, inserts line breaks into a header value so the attacker can add headers or split a response. Scanner reports often use the two names loosely. Host header injection has a different cause and a different fix.
Applications trust it wherever they need their own address. Testers check these places first.
- Password reset and email verification links built from the request host.
- Absolute URLs in redirects, canonical tags, Open Graph tags and script imports.
- Cache keys and cached responses (see web cache poisoning).
- Virtual host routing and internal-only sites on the same server.
- Allowlist checks that read
X-Forwarded-HostwhenHostis missing. - Redirect targets that feed an open redirect.
OWASP's WSTG adds a point about overrides: if the server rejects a bad Host, the tester can keep the real Host and put the test domain in X-Forwarded-Host, where the value may appear in page output.
How the attack works: password reset poisoning#
The attacker asks for a password reset for a victim's account with a Host header the attacker controls. The application builds the reset link from that header, so the victim receives a real email whose link points to the attacker's domain. If the victim clicks, the reset token goes to the attacker.
Demonstration only: Harbor Notes, a fictional app
This is a demonstration against a fictional app. No real customer system was tested. The app runs at https://notes.example.com. The tester uses a test-owned account, tester@example.com, and a mailbox the tester controls.
The reset request carries a canary host the authorization covers:
POST /account/forgot-password HTTP/1.1
Host: canary.example.net
Content-Type: application/x-www-form-urlencoded
email=tester%40example.com
The email the app sends to the test mailbox contains:
Reset your password:
https://canary.example.net/account/reset?token=<redacted>
The link host came from the request, not from configuration. The finding is the email, not a login to a real account.
OWASP's description of this case says the victim's click sends the secret token to the attacker, who can then reset the password. The password reset vulnerabilities page covers the rest of that flow.
The main variants#
Testers vary two things: where the value goes, and which header carries it. The table lists the cases in PortSwigger's and OWASP's guidance.
| Variant | How the value is delivered | What the attacker gets |
|---|---|---|
| Changed Host | Another domain in Host |
Redirect to that domain, wrong virtual host, poisoned links |
| Override header | Real Host, test domain in X-Forwarded-Host |
The same effects when Host is validated but overrides are not |
| Missing Host | No Host, only X-Forwarded-Host |
Allowlist bypass (CVE-2026-48061) |
| Reset poisoning | Changed host on a reset request | Token sent to the attacker's domain |
| Cache poisoning | Changed host on a cacheable page | Poisoned copy served to other users |
| Routing-based SSRF | A host that points to an internal name | Requests routed to internal systems |
| Virtual host brute force | Guessed internal hostnames | Discovery of internal-only sites |
Port injection is a variation testers also try: a value after the colon in Host that the application copies into URLs. Check that case when the application writes the port into links.
Real incidents and CVEs#
Reset poisoning keeps appearing in shipped software. Each entry is from the National Vulnerability Database (NVD), checked 9 November 2026.
| CVE | Published | Product | What NVD says |
|---|---|---|---|
| CVE-2022-29933 | 9 May 2022 | Craft CMS through 3.7.36 | An unauthenticated attacker who knew a valid username could send X-Forwarded-Host to the password reset endpoint and take over the account. The vendor's position is that a configuration change works around it. |
| CVE-2025-61132 | 23 Oct 2025 | braindump v0.4.14 | Password reset poisoning when Flask's url_for(_external=True) builds reset links without a fixed SERVER_NAME. |
| CVE-2026-29199 | 4 May 2026 | phpBB before 3.3.16 | With force_server_vars disabled, the host from the Host header builds the reset link, which can lead to account takeover. |
| CVE-2026-48061 | 3 Aug 2026 | Litestar before 2.22.0 | The allowed-hosts middleware trusted X-Forwarded-Host as a fallback when Host was absent, so an attacker could bypass the check with a whitelisted value. Fixed in 2.22.0. |
The Litestar entry shows why a host allowlist is not finished until it covers the override headers too. The braindump and phpBB entries show the same flaw in the default way each framework builds absolute URLs.
How penetration testers detect and prove it#
OWASP's method starts simple: supply another domain in the Host header and see how the server processes it. The result depends on how the server treats the value, so the tester records what changed.
- Send a baseline request, then repeat it with a canary domain in
Host. Record the status code, redirects and any place the canary appears in the body or headers. - If the server rejects the changed
Host, keep the realHostand setX-Forwarded-Hostto the canary. TryX-HostandForwardedtoo. - Test a request with no
Hostand only an override header, a duplicateHostheader, and an absolute URL in the request line. These show how a proxy and the application disagree. - For each place the canary appears, say what the application does with it: a link, a redirect, a script import or a routing choice.
- For reset poisoning, use a test-owned account and a mailbox you control. Read the email and record the link host.
- For cache effects, add a unique cache buster and confirm with a second client, as in the cache poisoning method.
- Run controls. Send the same request with the real host and confirm the link is correct. Send a reset for a nonexistent account and compare the response, because a different message is a user enumeration finding of its own.
Evidence is labeled "Observed" for the requests, responses and email, and "Browser-observed" when a poisoned page runs in Chromium. A reflected canary in a comment or unused tag is an observation, not a high finding. A reset email with an attacker-chosen host is a finding. Never trigger a reset for a real user.
| Symptom | What it can mean | Fix |
|---|---|---|
| Canary appears in the reset email link | Reset poisoning | Fixed base URL in config |
| Canary appears in a cached page | Cache poisoning | Fixed base URL, cache key, strip override |
| Redirect to the canary host | Open redirect through Host | Relative redirects, allowlist |
| Request accepted for any host and reaches the first site | Missing catch-all server | Default server that rejects unknown hosts |
Request with only X-Forwarded-Host passes validation |
Allowlist trusts override | Strip override at the edge, validate the final host |
How to prevent host header injection#
PortSwigger's prevention tips fit one rule set. Avoid the header in server-side code. Set the public domain in configuration for absolute URLs. Validate the host against an allowlist. Disable override headers. Restrict routing to named hosts. Keep internal-only applications off public servers.
The same idea in code: the base URL is a constant, and the request host is checked, not trusted.
const BASE_URL = process.env.PUBLIC_BASE_URL; // https://notes.example.com
function resetLink(token) {
return `${BASE_URL}/account/reset?token=${encodeURIComponent(token)}`;
}
The table lists the per-stack setting, with what each project's documentation says.
| Stack | Setting | What the documentation says |
|---|---|---|
| Django | ALLOWED_HOSTS |
Default is an empty list. With DEBUG=False and no value, all requests get 400. Validation applies through get_host(), so reading request.META directly bypasses it. |
| Rails | config.hosts |
Empty outside development, so no Host check runs unless you add hosts. A regexp is anchored on both ends. Unauthorized hosts get 403. |
| Laravel | trustHosts middleware |
By default Laravel answers any Host and uses it for absolute URLs. Names are regular expressions, and subdomains of the app URL are trusted unless you set subdomains: false. |
| Express | trust proxy |
Default false. When enabled, req.hostname comes from X-Forwarded-Host, which the client or proxy can set. With true, the last trusted proxy must overwrite that header. |
| Spring Boot | server.forward-headers-strategy |
NONE outside supported cloud platforms. NATIVE or FRAMEWORK make the app honor forwarded headers, so use them only behind a proxy that sets them. |
| ASP.NET Core | AllowedHosts |
Kestrel does not validate Host. Host filtering is off until you set AllowedHosts, a semicolon-delimited list without ports. |
| nginx | Catch-all default_server |
A default server with return 444 rejects unknown hosts. |
| Apache | ServerName, UseCanonicalName On |
With On, self-referential URLs use ServerName, not the client's header. |
Python (Django). List exact names. Avoid '*' unless a first middleware validates the host.
ALLOWED_HOSTS = ["notes.example.com", "www.notes.example.com"]
# USE_X_FORWARDED_HOST stays False unless a trusted proxy sets it
Ruby (Rails).
Rails.application.config.hosts << "notes.example.com"
PHP (Laravel).
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustHosts(at: ['^notes\.example\.com$'], subdomains: false);
})
Node.js (Express). Trust only the proxy hop you run, and do not read the hostname for links.
app.set("trust proxy", 1); // one trusted proxy that overwrites X-Forwarded-*
Java (Spring Boot). Choose the strategy that matches the proxy, and keep Tomcat's trusted proxy range narrow. The documentation says not to trust all proxies in production.
server.forward-headers-strategy=NATIVE
C# (ASP.NET Core).
{ "AllowedHosts": "notes.example.com" }
Go. The standard library does not check hosts for you. Reject anything outside a fixed set.
var allowed = map[string]bool{"notes.example.com": true}
func hostGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h, _, err := net.SplitHostPort(r.Host)
if err != nil {
h = r.Host
}
if !allowed[strings.ToLower(h)] {
http.Error(w, "bad host", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}
nginx. A catch-all server stops unknown names from reaching an app.
server {
listen 80 default_server;
server_name _;
return 444;
}
Apache.
ServerName notes.example.com
UseCanonicalName On
How should proxies and CDNs handle X-Forwarded-Host?#
The edge should remove any client-supplied X-Forwarded-Host and set its own, or the application should ignore the header. Express documents the risk: with trust proxy on, req.hostname is taken from that header, which the client or proxy can set, so the last trusted proxy must overwrite it.
Check three things in the proxy layer. First, whether the proxy forwards the original Host or rewrites it. Second, whether it passes a client's X-Forwarded-* headers through. Third, whether the application validates the final host after the proxy has run, as the Litestar fix did. If a cache sits in the path, remember that the cache key usually contains Host and may not contain an override header.
Testing checklist#
- Is every place that builds an absolute URL listed (email, redirects, tags, imports)?
- Does the changed
Hostheader alter any of them? - Do
X-Forwarded-Host,X-HostandForwardedalter them? - Does a request with no
Hostand only an override pass validation? - Does the reset email use a fixed domain for a test-owned account?
- Does the server have a catch-all default that rejects unknown hosts?
- Does a cached page contain the canary after a buster test?
- Do controls with the real host stay correct?
When to get a professional test#
Host handling spans the application, the web server, the proxy and the CDN, so one layer's test can miss another layer's trust. A tester needs written scope and a test-owned account and mailbox to prove reset poisoning safely. Excession Labs checks host handling as part of a web application penetration test, with the evidence standard described above.
Questions
Is host header injection the same as HTTP header injection?
No. Host header injection abuses the value of one request header that the application trusts. HTTP header injection, also called CRLF injection, inserts line breaks into a response so the attacker can add headers or split the response. The two need different fixes, so check which one a scanner reports.
What is X-Forwarded-Host and why does it matter?
X-Forwarded-Host is a non-standard header that proxies add to tell the application which host the client asked for. Many frameworks read it in place of Host. If the edge does not strip a client-supplied copy, an attacker can set it. PortSwigger lists override headers as a way around Host validation.
Can I fix host header injection with a WAF alone?
A WAF rule can block obvious values, but the fix belongs in the application and server: fixed base URLs, an allowed-hosts list and a catch-all virtual host. Django's documentation says Host header attacks are possible even under many seemingly safe web server configurations.
How do I test host header injection without emailing real users?
Use a test-owned account and a mailbox you control. Send the password reset request with a canary hostname you own in the header, then read the email link. Never trigger resets for real users. For cache tests, add a unique cache buster to the URL.
Does setting ALLOWED_HOSTS to a wildcard remove the protection?
In Django, a value of '*' matches anything, and the documentation says you must then validate the Host header yourself, for example in a middleware listed first. A leading-dot value such as '.example.com' matches the domain and its subdomains, so every subdomain you list becomes trusted.
Sources
- PortSwigger Web Security Academy: HTTP Host header attacks (checked 9 November 2026)
- OWASP WSTG-INPV-17: Testing for Host Header Injection (v4.2) (checked 9 November 2026)
- Django documentation: Settings, ALLOWED_HOSTS (checked 9 November 2026)
- Rails Guides: Configuring Rails Applications, config.hosts (checked 9 November 2026)
- Laravel 12.x documentation: HTTP Requests, Configuring Trusted Hosts (checked 9 November 2026)
- Express: Behind proxies (trust proxy) (checked 9 November 2026)
- Spring Boot: Running behind a front-end proxy server (checked 9 November 2026)
- Microsoft Learn: Host filtering with ASP.NET Core Kestrel (checked 9 November 2026)
- nginx documentation: Server names (checked 9 November 2026)
- Apache HTTP Server 2.4: core directives (UseCanonicalName, ServerName) (checked 9 November 2026)
- NVD: CVE-2022-29933 (Craft CMS) (checked 9 November 2026)
- NVD: CVE-2025-61132 (braindump) (checked 9 November 2026)
- NVD: CVE-2026-29199 (phpBB) (checked 9 November 2026)
- NVD: CVE-2026-48061 (Litestar) (checked 9 November 2026)