# Host header injection: how it works, how testers prove it, how to fix it

Source: https://penetrationtestservices.com/vulnerabilities/host-header-injection/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Host header injection explained: password reset poisoning, cache poisoning and routing bugs, real CVEs, how testers prove it safely, and fixes for eight stacks.

> **Short answer:** 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](/vulnerabilities/web-cache-poisoning/)).
- Virtual host routing and internal-only sites on the same server.
- Allowlist checks that read `X-Forwarded-Host` when `Host` is missing.
- Redirect targets that feed an [open redirect](/vulnerabilities/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:
> 
> ```http
> 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:
> 
> ```text
> Reset your password:
> https://canary.example.net/account/reset?token=
> ```
> 
> 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](/vulnerabilities/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.

1. 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.
2. If the server rejects the changed `Host`, keep the real `Host` and set `X-Forwarded-Host` to the canary. Try `X-Host` and `Forwarded` too.
3. Test a request with no `Host` and only an override header, a duplicate `Host` header, and an absolute URL in the request line. These show how a proxy and the application disagree.
4. For each place the canary appears, say what the application does with it: a link, a redirect, a script import or a routing choice.
5. For reset poisoning, use a test-owned account and a mailbox you control. Read the email and record the link host.
6. For cache effects, add a unique cache buster and confirm with a second client, as in the cache poisoning method.
7. 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.

```js
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.

```python
ALLOWED_HOSTS = ["notes.example.com", "www.notes.example.com"]
# USE_X_FORWARDED_HOST stays False unless a trusted proxy sets it
```

**Ruby (Rails).**

```ruby
Rails.application.config.hosts 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.

```js
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.

```properties
server.forward-headers-strategy=NATIVE
```

**C# (ASP.NET Core).**

```json
{ "AllowedHosts": "notes.example.com" }
```

**Go.** The standard library does not check hosts for you. Reject anything outside a fixed set.

```go
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.

```nginx
server {
    listen 80 default_server;
    server_name _;
    return 444;
}
```

**Apache.**

```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 `Host` header alter any of them?
- Do `X-Forwarded-Host`, `X-Host` and `Forwarded` alter them?
- Does a request with no `Host` and 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](/web-application-penetration-testing/), 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](https://portswigger.net/web-security/host-header)
- [OWASP WSTG-INPV-17: Testing for Host Header Injection (v4.2)](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection)
- [Django documentation: Settings, ALLOWED_HOSTS](https://docs.djangoproject.com/en/5.2/ref/settings/#allowed-hosts)
- [Rails Guides: Configuring Rails Applications, config.hosts](https://guides.rubyonrails.org/configuring.html)
- [Laravel 12.x documentation: HTTP Requests, Configuring Trusted Hosts](https://laravel.com/docs/12.x/requests)
- [Express: Behind proxies (trust proxy)](https://expressjs.com/en/guide/behind-proxies.html)
- [Spring Boot: Running behind a front-end proxy server](https://docs.spring.io/spring-boot/how-to/webserver.html)
- [Microsoft Learn: Host filtering with ASP.NET Core Kestrel](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/host-filtering)
- [nginx documentation: Server names](https://nginx.org/en/docs/http/server_names.html)
- [Apache HTTP Server 2.4: core directives (UseCanonicalName, ServerName)](https://httpd.apache.org/docs/2.4/mod/core.html#usecanonicalname)
- [NVD: CVE-2022-29933 (Craft CMS)](https://nvd.nist.gov/vuln/detail/CVE-2022-29933)
- [NVD: CVE-2025-61132 (braindump)](https://nvd.nist.gov/vuln/detail/CVE-2025-61132)
- [NVD: CVE-2026-29199 (phpBB)](https://nvd.nist.gov/vuln/detail/CVE-2026-29199)
- [NVD: CVE-2026-48061 (Litestar)](https://nvd.nist.gov/vuln/detail/CVE-2026-48061)
