SecLab

HTTP Host header attacks

A05CWE-644CWE-20
01

What it is

A Host header attack is when the application trusts the Host header value (or X-Forwarded-Host) — a value the client sends and the attacker changes freely — and uses it to build URLs, select configuration, or route. The typical outcomes are password-reset poisoning (the reset link points at the attacker's domain) and cache poisoning.

02

Why you should care

Relevance: CoreExpected: L2

The counter-intuitive point to grasp: Host is user input, even though it looks like part of the infrastructure. Developers treat Host as "my domain" because in a normal browser it is — but curl -H "Host: evil" changes it in a second, and the server still processes the request.

The two largest outcomes, both from using Host to build something:

  • Password-reset poisoning. If the code builds the reset link with $"https://{Request.Host}/reset?token=...", the attacker sends Host: evil.example, and the victim gets an email with a link to evil.example — clicking it sends the reset token to the attacker. Account takeover, and it sits in exactly the reset flow the authentication topic warns nobody looks at.
  • Cache poisoning. If Host (or X-Forwarded-Host) is reflected into a response and the response is cached, one malicious request makes every subsequent user receive the poisoned response (see the web-cache-poisoning topic).

And X-Forwarded-Host is its own trap: teams add it to support a reverse proxy, then trust it — but if the proxy does not reset it, the client can send X-Forwarded-Host: evil, and many frameworks prefer that header over Host.

03

How the attack works

The mechanism: a request arrives with a client-controlled Host, and the application uses it somewhere sensitive instead of a configured value.

Diagram source
flowchart TD    A["Attacker sends:<br/>POST /password-reset<br/>Host: evil.example<br/>email=victim@acme.com"] --> S{How does the app use Host?}    S -->|"Builds the link from Request.Host"| P["Link: https://evil.example/reset?token=..."]    P --> M["Email to the VICTIM with that link"]    M --> V["Victim clicks → token goes to evil.example"]    V --> X["🔓 Account takeover"]    S -->|"Uses the CONFIGURED domain"| G["Link: https://app.example/reset?token=...<br/>the malicious Host is ignored"]

The key point: the malicious value reaches the VICTIM, it does not stay with the attacker. The attacker sends the request with the victim's email, but the poisoned link lands in the victim's inbox — so this is an attack on someone else, like CSRF and request smuggling.

A table of exploits and the relevant header:

ExploitHeaderFix belongs to
Password-reset poisoningHost, X-Forwarded-HostBuild the link from the configured domain
Cache poisoningHost, X-Forwarded-HostDo not reflect Host, or key the cache on Host (the web-cache-poisoning topic)
Misrouting / internal SSRFHost selecting a vhost/backendAllowlist Host at the proxy
Bypassing a check via X-Forwarded-Hostthe client sends the proxy headerThe proxy resets it, the app trusts a trusted proxy

The last row is the common trap: an app checks Host correctly, but its framework prefers X-Forwarded-Host (to support a proxy), and the client can send that header directly — so the Host check is bypassed. This is why the fix must decide explicitly which proxy to trust and which of its headers.

Diagram description: A branching diagram for a POST /password-reset request with a Host header of evil.example and the victim's email. The wrong branch builds the reset link from Request.Host, so the link points at https://evil.example/reset with a token, and the email with that link lands in the victim inbox. The victim clicks and the reset token goes to evil.example, becoming account takeover. The right branch uses the configured domain to build the link, so it points at app.example and the malicious Host is ignored.

04

Concrete example

A password-reset flow builds the link from Host — and Host is user input.

HTTP
# The attacker sends a reset request with the VICTIM's email but their own Host.POST /api/password-reset HTTP/1.1Host: evil.exampleContent-Type: application/json {"email":"victim@acme.com"}
# The email lands in the VICTIM inbox, with a link built from the malicious Host:Click to reset your password: https://evil.example/reset?token=a1b2c3...# The victim clicks (the link looks legitimate, from the app's real email), and the# reset token goes to the attacker's server. Account takeover without a password.
HTTP
# X-Forwarded-Host variant: the app checks Host correctly, but the framework prefers this header.POST /api/password-reset HTTP/1.1Host: app.example                    ← correct, passes the checkX-Forwarded-Host: evil.example       ← but the framework uses this to build the link {"email":"victim@acme.com"}
HTTP
# Cache poisoning: Host is reflected into a response and the response is cached.GET / HTTP/1.1Host: evil.example HTTP/1.1 200 OK<link rel="canonical" href="https://evil.example/">   ← reflected, and cached# Every subsequent user gets a page linking to evil.example (see the web-cache-poisoning topic).
# After the fix: the link is built from the CONFIGURED domain, not Host. The malicious Host is ignored.
C#One line: the reset link built from Request.Host, and Host is user input.
[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req, CancellationToken ct){    var user = await _users.FindByEmailAsync(req.Email, ct);    if (user is null) return Ok();   // giống hệt cho email tồn tại/không (topic authentication)     var token = await _resets.IssueAsync(user, ct);     // ❌ Request.Host là INPUT NGƯỜI DÙNG — curl -H "Host: evil.example" đổi nó trong    //    một giây. Kẻ tấn công gửi reset với email NẠN NHÂN nhưng Host của mình, và    //    link poisoned đi vào hộp thư nạn nhân. Bấm vào = token tới evil.example =    //    account takeover không cần mật khẩu.    var link = $"https://{Request.Host}/reset?token={token}";     await _mail.SendAsync(user.Email, $"Reset your password: {link}", ct);    return Ok();}
05

What happened in the wild

Password-reset poisoning (many bug bounty reports, ~2016–present). The recurring pattern is almost always the same: the reset flow builds the link from Host (or X-Forwarded-Host), so a request with the attacker's Host sends the reset token to their domain. Memorable because it is a complete account takeover in a single request, and it sits in exactly the reset flow the authentication topic warns is under-reviewed.

James Kettle's research — "Practical HTTP Host header attacks" (2013) and "Cracking the lens" (2017). Defined the class and showed Host/X-Forwarded-Host reaching surprising places: password reset, the cache key, even SQL and templates when Host is logged or stored. It is the source of the exploit table in block 3.

Host-routing CVEs in reverse proxies and frameworks. Several cases where an app uses Host to select a tenant or backend, so a forged Host routes the request to an internal resource — a form of SSRF (the ssrf topic) via a header. It illustrates block 2's point: Host is input, and routing on input is dangerous.

06

How to defend

Layer 1

Build URLs from the CONFIGURED domain, not from `Host`

mandatory

This is the fix for password-reset poisoning and most of the class: do not use Request.Host to build any URL that leaves the server (a link in an email, a canonical URL, an absolute redirect).

  • Take the domain from configuration: a PublicBaseUrl constant in appsettings, not from the request. The reset link is $"{_config.PublicBaseUrl}/reset?token=...". A malicious Host has no way in.
  • Especially for email: this is where the value reaches the victim, so it is the most dangerous place. See authentication layer 1b — the reset flow is already an under-reviewed surface; do not let it build a link from Host.

How to find it: grep Request.Host, X-Forwarded-Host, HttpContext...Host in the code (block 7). They should almost never appear outside the infrastructure layer — if a handler uses them to build a URL, that is a vulnerability.

C# · Layer 1Link from the configured domain, host-filtering allowlist, and forwarded headers from a trusted proxy.
public sealed class PasswordResetService(IOptions<AppOptions> opts, IMailer mail, IResetStore resets){    // Domain lấy từ CẤU HÌNH, không từ request. Đây là bản vá cốt lõi: một giá trị    // hằng trong appsettings mà Host độc hại không có đường chạm tới.    private readonly Uri _publicBaseUrl = new(opts.Value.PublicBaseUrl);   // "https://app.example"     public async Task RequestResetAsync(string email, CancellationToken ct)    {        var user = await _users.FindByEmailAsync(email, ct);        if (user is null) return;         var token = await resets.IssueAsync(user, ct);         // Link dựng từ _publicBaseUrl, KHÔNG từ Request.Host. Kẻ tấn công gửi        // Host: evil.example bao nhiêu lần cũng không đổi được domain trong link.        var link = new Uri(_publicBaseUrl, $"/reset?token={token}");         await mail.SendAsync(user.Email, $"Reset your password: {link}", ct);    }} // ── Program.cs · host filtering + forwarded headers ─────────────────────────// AllowedHosts là danh sách domain THẬT, không phải "*" (mặc định template là *).// Một request với Host ngoài danh sách bị từ chối 400 ở tầng framework.builder.Services.Configure<HostFilteringOptions>(o =>{    o.AllowedHosts = ["app.example", "www.app.example"];});app.UseHostFiltering(); // X-Forwarded-Host chỉ tin từ proxy ĐÃ BIẾT, và chỉ khi proxy đặt lại nó. Không cấu// hình KnownProxies thì một client gửi X-Forwarded-Host: evil trực tiếp và framework// tin nó — vòng qua host filtering ở trên (bẫy ở hàng cuối bảng khối 3).builder.Services.Configure<ForwardedHeadersOptions>(o =>{    o.ForwardedHeaders = ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto;    o.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));   // chỉ reverse proxy của ta    o.ForwardLimit = 1;                                 // đúng một hop});app.UseForwardedHeaders();
Layer 1b

Allowlist the Host, and decide explicitly which proxy to trust

mandatory

Two parts: check the incoming Host, and handle the proxy headers correctly.

  • Allowlist Host at the proxy/framework layer. A request with a Host not on your domain list is refused with 400. In ASP.NET Core: app.UseHostFiltering() with AllowedHosts — it exists but many leave AllowedHosts: "*" (the template default).
  • Trust X-Forwarded-Host only from a trusted proxy, and only when the proxy RESETS it. Configure ForwardedHeadersOptions with KnownProxies/KnownNetworks — otherwise a client sends X-Forwarded-Host: evil directly and the framework trusts it, bypassing the Host check. This is the trap in the last row of the block 3 table.
  • The proxy must RESET (not append) the X-Forwarded-* headers from the client. A client sending X-Forwarded-Host that the proxy forwards intact is the proxy trusting user input.

The important point: this is an infrastructure configuration decision, and it must be explicit — "trust proxy X, header Y from it" — not an accidental default.

Layer 2

Do not reflect Host into a cacheable response

This intersects the web-cache-poisoning topic. If Host (or X-Forwarded-Host) is reflected into a response — a canonical link, a <base href>, an absolute URL in HTML — and that response is cached, one malicious request poisons the cached copy for everyone after.

  • Do not reflect Host. Use relative URLs or the configured domain (layer 1) for every URL in the HTML.
  • If you must reflect it: put Host in the cache key so each Host's copy is separate (the web-cache-poisoning topic), or set Cache-Control: private/no-store on Host-dependent pages.

And Vary is not enough here: some CDNs do not honour Vary: Host (because Host is not supposed to vary), so you must configure the cache key at the CDN layer, not merely trust the header.

Layer 3

Detection: a Host outside the allowlist is a low-noise signal

A legitimate request to your app carries exactly one set of Host values — the domains you serve. So a different Host, or an X-Forwarded-Host from the client, is an attempt.

  • Log every request with a Host outside the allowlist, with the path. Alert on the rate — and pay particular attention to requests to /password-reset with an unusual Host, because that is poisoning in progress.
  • Log X-Forwarded-Host arriving from outside the trusted proxy range — the client should not send it, so its appearance is a probe.

Layer 3 because the layer-1 allowlist already blocks; logging turns "blocked" into "we know who is trying to poison whose reset".

07

Verifying the fix

1. Test password reset with a forged Host — the direct poisoning check:

Shell
B=https://staging.example.com# Send a reset with the attacker's Host, then check the link in the email.curl -s -X POST "$B/api/password-reset" -H 'Host: evil.example' \  -H 'Content-Type: application/json' -d '{"email":"test@acme.com"}'# The link in the email MUST point at app.example, NOT evil.example.

2. Test the X-Forwarded-Host variant — the common trap. Send a correct Host: app.example but X-Forwarded-Host: evil.example and assert the link still points at app.example. See the csharp / test tab.

3. Grep for using Host to build URLs:

Shell
grep -rnE 'Request\.Host|X-Forwarded-Host|HttpContext.*\.Host' --include='*.cs' src/ \  | grep -viE 'HostFiltering|ForwardedHeaders' \  && echo "WARNING: possibly using Host to build URLs — review each site"

4. Check host filtering is on, not *:

Shell
grep -rn 'AllowedHosts' src/ appsettings*.json \  | grep -q '"\*"' && { echo "AllowedHosts is * — every Host is accepted"; exit 1; }exit 0# And test directly: an unknown Host must be 400.curl -s -o /dev/null -w '%{http_code}\n' "$B/" -H 'Host: evil.example'   # must be 400

5. Check X-Forwarded-Host from the client is ignored:

Shell
# Send directly (not through a trusted proxy) → the framework must NOT use it.curl -si "$B/" -H 'Host: app.example' -H 'X-Forwarded-Host: evil.example' \  | grep -i 'evil.example' && { echo "X-Forwarded-Host from the client is trusted"; exit 1; }exit 0
C#A forged Host and a forged X-Forwarded-Host — both must be ignored when building the link.
public class HostHeaderTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public HostHeaderTests(ApiFixture fx) => _fx = fx;     /// <summary>    /// Password-reset poisoning — account takeover đầy đủ với một request. Gửi reset    /// với Host của kẻ tấn công và khẳng định link trong email trỏ tới domain THẬT.    /// </summary>    [Fact]    public async Task Reset_link_ignores_a_forged_host()    {        await _fx.SeedUserAsync("victim@acme.com");         var req = new HttpRequestMessage(HttpMethod.Post, "/api/password-reset")        {            Content = JsonContent.Create(new { email = "victim@acme.com" }),        };        req.Headers.Host = "evil.example";   // Host giả         await _fx.Client.SendAsync(req);         var link = _fx.ExtractLinkFromLastEmail();        Assert.StartsWith("https://app.example/", link);        Assert.DoesNotContain("evil.example", link);    }     /// <summary>    /// Bẫy X-Forwarded-Host: Host đúng qua được phép kiểm, nhưng nhiều framework ưu    /// tiên X-Forwarded-Host để dựng URL. Client gửi trực tiếp (không qua proxy tin    /// cậy) → phải bị bỏ qua.    /// </summary>    [Fact]    public async Task Reset_link_ignores_x_forwarded_host_from_the_client()    {        await _fx.SeedUserAsync("victim@acme.com");         var req = new HttpRequestMessage(HttpMethod.Post, "/api/password-reset")        {            Content = JsonContent.Create(new { email = "victim@acme.com" }),        };        req.Headers.Host = "app.example";                       // đúng        req.Headers.Add("X-Forwarded-Host", "evil.example");    // nhưng client gửi cái này         await _fx.Client.SendAsync(req);         Assert.DoesNotContain("evil.example", _fx.ExtractLinkFromLastEmail());    }     /// <summary>Host ngoài allowlist bị từ chối 400 ở tầng framework.</summary>    [Fact]    public async Task Unknown_host_is_rejected()    {        var req = new HttpRequestMessage(HttpMethod.Get, "/");        req.Headers.Host = "evil.example";         Assert.Equal(HttpStatusCode.BadRequest, (await _fx.Client.SendAsync(req)).StatusCode);    }     /// <summary>Host phản chiếu vào response được cache là cache poisoning — không được có.</summary>    [Fact]    public async Task Cacheable_responses_do_not_reflect_the_host()    {        var req = new HttpRequestMessage(HttpMethod.Get, "/");        req.Headers.Host = "app.example";   // hợp lệ         var res = await _fx.Client.SendAsync(req);        var body = await res.Content.ReadAsStringAsync();         // Nếu response cacheable, nó KHÔNG được chứa Host trong canonical/base/absolute URL —        // dùng URL tương đối hoặc domain cấu hình. Ở đây kiểm nó không phản chiếu Host thô.        if (res.Headers.CacheControl?.Public == true)            Assert.DoesNotContain("app.example", body);   // chỉ URL tương đối trong HTML cacheable    }}
08

Common mistakes

The "fix"Why it is wrong
Build the reset link from Request.HostHost is user input. Password-reset poisoning → account takeover. Use the configured domain
Check Host but the framework prefers X-Forwarded-HostThe client can send X-Forwarded-Host directly, bypassing the check. Configure KnownProxies
AllowedHosts: "*" (the template default)Every Host is accepted. Set your real domain list
Trust X-Forwarded-Host "because there is a proxy"Trust it only when the proxy RESETS it and the request comes from a trusted proxy
Reflect Host into a cached pageCache poisoning for everyone after. Use relative URLs / the configured domain
Vary: Host to separate the cacheSome CDNs do not honour it (Host is not supposed to vary). Configure the cache key at the CDN
Treat Host as "my domain"curl -H "Host: evil" changes it in a second. It is input, not infrastructure

The perception mistake, and it is the central one: treating Host as a trusted part of the infrastructure. It is a client-sent header, changed freely with one line of curl. Every place that uses Host as though it were your domain is a waiting vulnerability.

The scoping mistake: fixing the reset flow and stopping. The same untrusted Host also reaches canonical URLs, absolute redirects, other emails, and the cache key. The way to find it is to grep every use of Host, not only the reset flow.

09

References

Tier 1Host filtering and forwarded headers in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Tier 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Tier 2HTTP Host header attacks · PortSwigger · Web Security Academy
Tier 2Practical HTTP Host header attacks · James Kettle, Skeleton Scribe
Tier 3Password reset poisoning · PortSwigger
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…