Cache Poisoning
Get a malicious response stored in a shared cache, and it is served to everyone who asks for that URL.
Web cache poisoning turns one crafted request into a response served to every subsequent visitor. The attacker does not need to reach the victims; the cache delivers for them.
How it works
A cache stores responses keyed on some part of the request — usually the method, host and path. Anything not in the key is called an unkeyed input. If an unkeyed input influences the response, an attacker can send a request that produces a malicious response, which is then stored under a perfectly normal key and served to everyone.
The usual unkeyed inputs
X-Forwarded-HostandX-Forwarded-Scheme— commonly used to build absolute URLs, and rarely in the cache key.X-Original-URL,X-Rewrite-URL— can change routing on some stacks.- Unkeyed query parameters, when the cache normalises the query string.
- Headers reflected into the response body or into a
Location.
Cache deception
The mirror image. An attacker persuades a victim to visit /api/me/account.css. The application ignores the suffix and returns the victim's data; the CDN sees .css, decides it is a static asset, and caches it publicly. The attacker then fetches the same URL and reads it.
Defences
- Do not cache authenticated responses.
Cache-Control: no-storeas the default removes most of the surface. - Do not use request headers to build responses. Absolute URLs come from configuration.
- Include every response-affecting input in the cache key, or strip it at the edge.
- Cache by extension only when the content type agrees — a
.cssURL returningapplication/jsonshould never be cached. - Normalise paths at the edge so
/api/me/x.cssdoes not reach an endpoint that ignores the suffix.
Example
# Poisoning through an unkeyed X-Forwarded-Host
curl https://dfg.com/api/config \
-H 'X-Forwarded-Host: evil.com'
# The application builds URLs from that header:
# { "assetsUrl": "https://evil.com/assets", "apiUrl": "https://evil.com/api" }
#
# The CDN keys on host+path only, so this response is stored for
# https://dfg.com/api/config and served to every subsequent visitor.
# Their browsers then load scripts from evil.com.
# Cache deception
curl https://dfg.com/api/me/profile.css # app ignores the suffix,
# CDN caches it as a static asset
curl https://dfg.com/api/me/profile.css # the attacker reads the victim's dataWhen to use it
- An unkeyed X-Forwarded-Host header lets an attacker replace the asset URL in a cached configuration response served to every visitor.
- A cache-deception attack stores a victim's account response under a .css URL that the attacker then fetches.
- A CDN caches an authenticated response because the application forgot no-store on one endpoint.
More examples
Never build URLs from request headers
This is the same fix as the password-reset Host header injection: one rule, applied to every absolute URL your application generates.
// ❌ The header is attacker-controlled AND usually unkeyed by the cache.
app.get('/api/config', (req, res) => {
const host = req.get('x-forwarded-host') ?? req.get('host');
res.json({
apiUrl: `https://${host}/api`,
assetsUrl: `https://${host}/assets`,
loginUrl: `https://${host}/login`,
});
});
// ✅ Configuration, not headers.
const APP_URL = process.env.APP_URL; // https://dfg.com, set at deploy
if (!APP_URL) throw new Error('APP_URL must be configured');
app.get('/api/config', (req, res) => {
res.set('Cache-Control', 'public, max-age=300');
res.json({
apiUrl: `${APP_URL}/api`,
assetsUrl: `${process.env.ASSETS_URL}`,
loginUrl: `${APP_URL}/login`,
});
});
// ✅ And reject unexpected hosts at the edge, as a second layer.
const ALLOWED_HOSTS = new Set(['dfg.com', 'www.dfg.com', 'api.dfg.com']);
app.use((req, res, next) => {
// Check BOTH the real host and any forwarded claim.
const host = (req.get('x-forwarded-host') ?? req.get('host') ?? '')
.split(':')[0].toLowerCase();
if (!ALLOWED_HOSTS.has(host)) {
logger.warn({ host, ip: req.ip }, 'unexpected host header');
return res.status(400).send('Bad Host');
}
next();
});
// ✅ Strip the headers you do not use, at the edge, so they cannot influence
// anything downstream.
// nginx:
// proxy_set_header X-Forwarded-Host "";
// proxy_set_header X-Original-URL "";
// proxy_set_header X-Rewrite-URL "";
// proxy_set_header X-Forwarded-Scheme "";
// The same rule appears throughout this course: absolute URLs — reset links,
// OAuth redirects, webhook callbacks, asset paths — come from configuration.
// Never from a request header.Cache keys and edge configuration
Rejecting static-looking extensions on API paths at the edge is the cleanest anti-deception control — it removes the ambiguity rather than trying to detect it.
# The rule: anything that can change the RESPONSE must be in the KEY, or must
# not reach the origin at all.
# ── Cloudflare ───────────────────────────────────────────────────────
# Transform rule: remove headers the origin must never see
# Remove: X-Forwarded-Host, X-Original-URL, X-Rewrite-URL, X-Forwarded-Scheme
#
# Cache rule: never cache anything credentialed
# (any(http.request.headers["authorization"][*] != "")
# or http.cookie contains "sid=")
# → Cache eligibility: Bypass cache
#
# Cache rule: only cache what is genuinely static
# (http.request.uri.path matches "^/(assets|static)/")
# → Cache Everything, Edge TTL 1 day
# ── CloudFront: include response-affecting headers in the key ────────
{
"CachePolicyConfig": {
"Name": "api-safe",
"ParametersInCacheKeyAndForwardedToOrigin": {
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": { "Items": ["Authorization", "Origin", "Accept"], "Quantity": 3 }
},
"CookiesConfig": { "CookieBehavior": "whitelist",
"Cookies": { "Items": ["sid"], "Quantity": 1 } },
"QueryStringsConfig": { "QueryStringBehavior": "all" }
}
}
}
# ── Cache DECEPTION: the extension must agree with the content type ──
# nginx — do not cache a response whose type contradicts the URL
map $upstream_http_content_type $deception {
default 0;
"~*application/json" 1; # JSON under a .css/.js URL is suspicious
}
location ~* \.(css|js|png|jpg|svg|woff2)$ {
proxy_no_cache $deception;
proxy_cache_bypass $deception;
proxy_pass http://app;
}
# ── And normalise paths so the trick does not reach the app at all ───
# Reject a static-looking extension on an API path outright:
location ~* ^/api/.*\.(css|js|png|jpg|svg|json|txt)$ {
return 404;
}
# ── Verify ───────────────────────────────────────────────────────────
# 1. Does an unkeyed header change the response?
curl -s https://dfg.com/api/config -H 'X-Forwarded-Host: evil.com' | grep evil
# any output = poisoning vector
# 2. Is a suffixed API path cached?
curl -sI https://dfg.com/api/me/x.css -H "Authorization: Bearer $TOKEN" \
| grep -iE 'cf-cache-status|x-cache|cache-control'
# expect BYPASS/MISS and no-store, or a 404Probing your own cache
The cache-buster discipline is the practical safety rule here: a poisoning probe without one is not a test, it is the attack.
#!/usr/bin/env bash
# Poisoning findings are usually found by trying unkeyed headers one at a time.
# Use a CACHE BUSTER so you never poison the real cache while testing.
set -uo pipefail
BASE="${1:-https://dfg.com}"
HEADERS=(
"X-Forwarded-Host: canary.example.com"
"X-Forwarded-Scheme: http"
"X-Forwarded-Server: canary.example.com"
"X-Host: canary.example.com"
"X-Original-URL: /admin"
"X-Rewrite-URL: /admin"
"X-Forwarded-Prefix: /canary"
"X-HTTP-Method-Override: DELETE"
)
for h in "${HEADERS[@]}"; do
# A unique query parameter keeps each probe in its own cache entry.
bust="cb=$RANDOM$RANDOM"
body=$(curl -s "$BASE/api/config?$bust" -H "$h")
if echo "$body" | grep -qi 'canary.example.com'; then
echo "❗ REFLECTED: $h"
# Now check whether it would have been CACHED:
status=$(curl -sI "$BASE/api/config?$bust" | grep -iE 'cf-cache-status|x-cache')
echo " cache status on repeat: $status"
echo " → if HIT, this is a poisoning vector"
fi
done
# ── Cache deception probe ────────────────────────────────────────────
for ext in .css .js .json .png .txt; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/me/x$ext" \
-H "Authorization: Bearer $TOKEN")
if [ "$code" = "200" ]; then
echo "❗ /api/me/x$ext returns 200 — the app ignores the suffix"
cached=$(curl -sI "$BASE/api/me/x$ext" | grep -iE 'cf-cache-status|x-cache')
echo " cache: $cached"
fi
done
# ⚠️ Only run this against systems you own. A successful poisoning test on
# someone else's infrastructure affects their real users.
#
# ⚠️ And use the cache buster. Testing without one can poison your own
# production cache for real visitors.
Discussion