Host header injection: a reproducible password-reset test
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.
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 describes authorized use. This article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py and 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. 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.
| 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. 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 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"} 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: all eight cases, headers sent, expected and observed status, link host and outbox state.
- Exact recorded curl commands: the ports refer to the finished run. Use the runner to create fresh servers.
- SHA-256 manifest: compare downloaded sources and evidence. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source and verification source.
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, where a server follows a client-influenced destination, and 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 post covers a related boundary, checking identity rather than origin.
Excession Labs checks Host handling and link generation as part of its security assessments. See how a finding like this looks in a sample report.