[Blog](https://excessionlabs.ai/blog/) · 10 November 2026 · [Excession Labs](https://excessionlabs.ai/about/)

# Host header injection: a reproducible password-reset test

Source page: https://excessionlabs.ai/blog/host-header-injection/

We reproduced host header injection against a small password-reset endpoint. Forging the request's Host header, and separately its X-Forwarded-Host header, produced a reset link that pointed to an attacker-named domain while carrying the victim's real synthetic token. Building the link from a configured origin and rejecting unrecognized Host values closed both paths, and the fixed server still returned the same reset for a legitimate request.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

On this page: [Scope](https://excessionlabs.ai/blog/host-header-injection/#scope) · [Reproduce the run](https://excessionlabs.ai/blog/host-header-injection/#reproduce) · [Observed results](https://excessionlabs.ai/blog/host-header-injection/#results) · [Verify the finding](https://excessionlabs.ai/blog/host-header-injection/#finding) · [Fix and retest](https://excessionlabs.ai/blog/host-header-injection/#fix) · [Limits](https://excessionlabs.ai/blog/host-header-injection/#limits)

## Define the host header injection test scope

A password-reset endpoint usually needs to build an absolute link back to the application: something like `https://app.example.com/reset?token=...`. A convenient way to get that link's host is to read it from the incoming request, either the Host header or a forwarded-host header set by a proxy in front of the application. The client sets the Host header. An attacker can set it, or a forwarded-host header, to any value. A server that trusts it emails the real victim a reset link that points to the attacker's host. If the victim, or a mail scanner, opens that link, the attacker's server receives the valid token.

Testers call this abuse password reset poisoning. Our fixture is one route: `POST /reset-password` with a JSON body `{"email": "..."}`. It holds one synthetic account, `victim@example.test`, with a reset token issued when the account was created. Instead of sending mail, the fixture appends the rendered message to a local outbox file, so the test can read exactly what a user would have received by email.

- **Target:** two short-lived HTTP servers bound to IPv4 loopback, `127.0.0.1`. The runner selects free ports.

- **Allowed requests:** eight POST requests in total, all to `/reset-password`.

- **Identity:** one synthetic account, `victim@example.test`, and a fixed demo reset token with no value outside this fixture.

- **Data:** an in-memory account table and a local JSON-lines outbox file. There is no real mail transport, external service or database.

The fixture is intentionally insecure in its vulnerable mode. It has no configurable bind address and always listens on loopback. For a staging engagement, agree the hosts, accounts, allowed methods and stopping conditions with the system owner before replacing this scope. The [Excession Labs Acceptable Use Policy](https://excessionlabs.ai/aup/) describes authorized use. This article gives no permission to test another system.

## Run the downloadable fixture

Download [fixture.py](https://excessionlabs.ai/blog/evidence/host-header-injection/fixture.py) and [verify.py](https://excessionlabs.ai/blog/evidence/host-header-injection/verify.py) into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:

```
python3 verify.py --output rerun
```

The output directory must be new. The runner starts a vulnerable and a fixed server in sequence, each with its own outbox file, sends four real curl requests to each, saves the responses and outbox contents, and stops the servers. Each curl request bypasses proxies with `--noproxy '*'`, has a one-second connection timeout and a three-second total timeout, and does not follow redirects.

Our recorded command was `python3 verify.py --output recorded-run`. It exited successfully with `{"checks": 8, "passed": true, "failures": []}` plus the evidence path. That means the demonstration matched its expected outcomes, including the intentionally vulnerable response.

The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, commands and source SHA-256 hashes are in [results.json](https://excessionlabs.ai/blog/evidence/host-header-injection/recorded-run/results.json). A rerun will use different ports, timestamps and response dates.

## Observed results across Host and X-Forwarded-Host

We sent a legitimate reset request, a request with a forged Host header, a request with a correct Host header but a forged X-Forwarded-Host header, and a request for an unknown address as a negative control. We repeated all four against a fresh server with the canonical-origin fix enabled.

Observed status and reset-link host from eight local requests

| Request | Host header | X-Forwarded-Host | Vulnerable mode | Fixed mode |
| --- | --- | --- | --- | --- |
| Legitimate reset | app.example.test | none | 202, link host app.example.test | 202, link host app.example.test |
| Forged Host | attacker.evil | none | 202, link host attacker.evil | 400: unrecognized_host |
| Forged X-Forwarded-Host | app.example.test | attacker.evil | 202, link host attacker.evil | 202, link host app.example.test |
| Unknown address | app.example.test | none | 202, no outbox entry | 202, no outbox entry |

The unknown-address row is a negative control. Both modes return the same generic 202 and write nothing to the outbox, so the fix does not start revealing which addresses have an account. The legitimate row is a positive control: a real user requesting a reset still gets a correct link after the fix.

## Verify the forged link before writing a finding

A host header injection finding is not visible in the HTTP response alone. In every case here, the API answered `202 {"status": "accepted"}` or the `400` rejection; the poisoned link only shows up in the message the server would have sent. This is the exact recorded command for the forged-Host case, reformatted across lines:

```
curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --header 'Content-Type: application/json' \
  --header 'Host: attacker.evil' \
  --data-binary '{"email": "victim@example.test"}' \
  http://127.0.0.1:54539/reset-password
```

The server answered `202 Accepted`, and the corresponding line appended to the vulnerable server's outbox file read:

```
{"body": "Reset your password: https://attacker.evil/reset?token=a93fd21c7e4b4f0f9d3b6c8a1e2f5d7c", "subject": "Password reset", "to": "victim@example.test"}
```

The link points to `attacker.evil`, a host the server never ran on, and it carries the same token a legitimate reset would issue for `victim@example.test`. Sending the forged value through X-Forwarded-Host instead of Host produced the same poisoned link in the vulnerable server's outbox, because that mode preferred the forwarded-host value when one was present. A finding here needs three facts together: the header value sent, the resulting link's host, and a token that still matches the targeted account. A 202 status by itself would be weak evidence, since the vulnerable and fixed servers return the same status for most of these requests.

The same manipulation is in [OWASP WSTG-INPV-17, Testing for Host Header Injection](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection/). Its summary lists password reset manipulation, redirects to an attacker domain and web cache poisoning. Its test steps suggest X-Forwarded-Host when the server blocks a forged Host.

## Build the link from a canonical origin and retest

The fix changes two things in the same fixture source. Fixed mode checks the Host header against an allowlist before doing anything else, and it stops reading the link's host from the request at all:

```
if self.server.mode == "fixed":
    if host_header not in ALLOWED_HOSTS:
        return self.reply(400, {"error": "unrecognized_host"})
    origin = CANONICAL_ORIGIN
else:
    origin = "https://" + (forwarded_host or host_header)
```

`CANONICAL_ORIGIN` is a configuration value. No header can change it. [RFC 9110 section 7.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.2) says that because Host acts as an application-level routing mechanism, it "is a frequent target for malware seeking to poison a shared cache or redirect a request to an unintended server." Section 7.4 adds that a Host value that differs from the connection's host can be an attack unless the connection comes from a trusted gateway, and defines 421 (Misdirected Request) for rejecting such requests. This fixture has no trusted gateway, so it rejects every unrecognized Host. It answers 400; 421 would also fit.

The runner restarted the server in fixed mode and repeated the same four requests. The forged-Host request returned [400 with `{"error": "unrecognized_host"}`](https://excessionlabs.ai/blog/evidence/host-header-injection/recorded-run/fixed-forged-host.http) and wrote nothing to the outbox. The forged-X-Forwarded-Host request still returned 202, because its Host header was the real one, but the outbox line now read `https://app.example.test/reset?token=...`: the forwarded-host value was ignored entirely. The legitimate request still produced a correct link and a 202. The runner checks the outbox contents as well as the status code, so a rejection that still wrote a poisoned link would fail its checks.

To inspect the servers manually in two separate terminals, use `python3 fixture.py --mode vulnerable --port 8765 --outbox /tmp/vuln.jsonl` and `python3 fixture.py --mode fixed --port 8766 --outbox /tmp/fixed.jsonl`, then send your own curl request with a custom Host or X-Forwarded-Host header and read the outbox file. Stop each server with Ctrl-C. The published run used the runner and ephemeral ports.

## Inspect the evidence bundle

- [Machine-readable results](https://excessionlabs.ai/blog/evidence/host-header-injection/recorded-run/results.json): all eight cases, headers sent, expected and observed status, link host and outbox state.

- [Exact recorded curl commands](https://excessionlabs.ai/blog/evidence/host-header-injection/recorded-run/commands.sh): the ports refer to the finished run. Use the runner to create fresh servers.

- [SHA-256 manifest](https://excessionlabs.ai/blog/evidence/host-header-injection/SHA256SUMS): compare downloaded sources and evidence. This is an integrity check, not independent attestation.

- [Fixture instructions and limitations](https://excessionlabs.ai/blog/evidence/host-header-injection/README.txt), [server source](https://excessionlabs.ai/blog/evidence/host-header-injection/fixture.py) and [verification source](https://excessionlabs.ai/blog/evidence/host-header-injection/verify.py).

All captured emails, tokens and hostnames are synthetic public demo data. The bundle includes the actual rendered outbox messages and HTTP response text, including server headers. No production credentials or customer data were involved.

## What this experiment does not cover

Eight requests against one toy endpoint demonstrate one Host-trusting boundary in one route. We did not test a real reverse proxy, TLS termination, SNI mismatches, cache poisoning, password-reset flows with multiple hops, header parsing edge cases such as duplicate or absolute-form Host values, or production infrastructure. A Host header attack against a different framework can behave differently depending on how that framework resolves the request's authority, and this fixed fixture's result cannot establish that an unrelated application is secure.

For a real engagement, enumerate every place a request header reaches an outbound URL, link, redirect or cache key. Password reset is one route among several; email verification links, invoice PDFs and webhook callbacks can carry the same flaw, and each needs its own retest after a fix. Keep failed or inconclusive runs alongside successful reproductions. Related classes worth testing alongside this one include [server-side request forgery](https://excessionlabs.ai/blog/ssrf/), where a server follows a client-influenced destination, and [open redirect](https://excessionlabs.ai/blog/open-redirect/), where a client-supplied target sends a user somewhere unintended; both share the pattern of trusting a value the client controls. Our [API authorization testing](https://excessionlabs.ai/blog/api-authorization-testing/) post covers a related boundary, checking identity rather than origin.

Excession Labs checks Host handling and link generation as part of its [security assessments](https://excessionlabs.ai/security-assessment/). See how a finding like this looks in a [sample report](https://excessionlabs.ai/sample-report/).
