Host Header InjectionCache Poisoning

Web Cache Poisoning via the Host Header

When a cache stores a response built from an unkeyed Host header, one attacker request can rewrite what every later visitor receives. Here is how unkeyed input becomes a stored attack and how to key, strip or stop reflecting it.

SelfSec Team5 min read
Part 2 of 2from theHost Header Injectionseries
X-Forwarded-Host: evil.attacker.com
On this page

Host header injection on its own affects one request: you spoof the Host, the app reflects it back to you. Put a cache in front of that app and the blast radius changes completely. If the response built from your spoofed header is stored and served to others, a single poisoned request rewrites the page for every subsequent visitor until the entry expires. The bug is the same reflection; the cache turns it from a self-inflicted trick into a stored attack on the whole audience.

The pivot is the difference between keyed and unkeyed input. A cache decides which stored response to return using a cache key — typically the method and URL. Headers like Host and X-Forwarded-Host usually are not part of that key, yet apps routinely reflect them into responses: absolute URLs, <link> and <script> src values, password-reset links. When an input influences the response but not the key, you can change what gets cached without changing what others request to retrieve it. This post shows that chain and how to break it.

How it happens

The application builds an absolute resource URL from the inbound host and emits it into the page. The cache in front then stores the whole response keyed only on the path:

@app.route('/welcome')
def welcome():
    host = request.headers.get('X-Forwarded-Host') or request.headers.get('Host')
    body = render_template('welcome.html', asset_base=f'https://{host}')
    resp = make_response(body)
    resp.headers['Cache-Control'] = 'public, max-age=600'
    return resp

The template drops asset_base into a script import — <script src="{{ asset_base }}/app.js">. The developer assumed the host would always be the real site. Two things break that. First, the value is client-supplied, and many apps trust X-Forwarded-Host ahead of Host, which an attacker sets freely. Second, the cache key is GET /welcome and nothing more — the header that shaped the response is unkeyed, so the response the attacker poisons is the one handed to everyone who asks for /welcome.

A concrete attack

The attacker probes the cache key first: send /welcome with a junk X-Forwarded-Host and a cache-buster query, then see whether the junk host comes back and whether a second request without it is served the stored copy. Once the header is confirmed unkeyed, they send the real payload:

GET /welcome HTTP/1.1
Host: shop.example
X-Forwarded-Host: evil.attacker.com

The app reflects the spoofed host into the script import, and the cache stores it against the plain /welcome key:

<script src="https://evil.attacker.com/app.js"></script>

Now every ordinary visitor requesting /welcome is served the cached page and their browser fetches app.js from the attacker's server — stored cross-site scripting delivered through a malicious script import, with no payload in their request at all. The same primitive poisons absolute links and <link> hrefs, and where the reflected host lands in an HTML attribute or text node it becomes reflected-then-stored XSS via a break-out payload in the header. One request; everyone downstream affected.

The victims requested a perfectly normal page. The attacker never touched their traffic — they only poisoned the copy the cache had already decided to hand out, through a header the key ignored.

What an attacker gains

Promoting a single reflection into a cached response scales the impact to the whole audience:

  • Stored XSS at scale — a poisoned <script src> or attribute break-out runs in every visitor's browser, not just the attacker's.
  • Malicious script and resource imports — pages load JavaScript, CSS or images from attacker infrastructure under the real site's origin.
  • Credential and recovery hijacking — cached pages whose reset or login links were built from the spoofed host send secrets to the attacker.
  • Site-wide defacement and redirection — one entry can reshape what thousands of users see until the TTL lapses.

The fix: don't reflect the host, and key or strip what the cache stores

The durable fix is at the origin: stop reflecting the request host into responses. Derive every absolute URL from a canonical, allowlisted host you configure, and validate the inbound Host against an explicit allowlist before any handler runs:

ALLOWED_HOSTS = {'shop.example', 'www.shop.example'}
ASSET_BASE = 'https://shop.example'

@app.before_request
def enforce_host():
    host = request.headers.get('Host', '')
    if host not in ALLOWED_HOSTS:
        abort(400)

@app.route('/welcome')
def welcome():
    body = render_template('welcome.html', asset_base=ASSET_BASE)
    resp = make_response(body)
    resp.headers['Cache-Control'] = 'public, max-age=600'
    return resp

Then close the cache side so a reflection can never become a shared response. Do not trust forwarding headers — X-Forwarded-Host, X-Host and Forwarded are attacker-controllable; only honor them from a proxy you control. At the cache, strip request headers the origin does not need, and where a header genuinely must vary the response, add it to the cache key (via the cache's keying configuration or a correct Vary) so a poisoned variant cannot be served to a different request. Never let an input shape a cached body while staying out of its key.

How SelfSec finds it

SelfSec crawls the target and replaces the Host header — along with related forwarding headers like X-Forwarded-Host, X-Host and Forwarded — with an attacker-controlled domain, then checks whether that value is reflected into generated links, redirects, script imports or other response output. To catch the cached case it probes with cache-buster queries and re-requests without the spoofed header, confirming a finding only when the attacker host actually surfaces in a response served back from cache — proving the input is reflected and unkeyed rather than guessing. The scan runs entirely on 127.0.0.1, your targets and findings stay on your machine, and every finding ships with the exact request to reproduce it.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Host Header Injection

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Host Header Injection series

  1. 01OverviewHost Header Injection: How a Spoofed Header Hijacks Password-Reset Links3 min
  2. 02Cache PoisoningWeb Cache Poisoning via the Host HeaderReading

Related reading