Web Cache Poisoning
Web cache poisoning exploits the gap between what a cache considers part of a request's identity and what the server uses to generate responses. By manipulating 'unkeyed' inputs that affect the response but aren't included in the cache key, attackers can poison caches to serve malicious content to everyone who visits a page.
How Web Caching Works
Web caches (CDNs, reverse proxies) store responses and serve them to subsequent requests. The cache uses a 'cache key' - typically the URL and Host header - to identify equivalent requests. However, other request components like headers and cookies may influence the response without being part of the cache key.
Request 1 (Attacker):
GET /page HTTP/1.1
Host: example.com
X-Forwarded-Host: evil.com <- Unkeyed input
Response (Poisoned):
<script src="https://evil.com/malicious.js"></script>
Cache stores this with key: "GET /page, Host: example.com"
Request 2 (Victim):
GET /page HTTP/1.1
Host: example.com
Response (Served from cache):
<script src="https://evil.com/malicious.js"></script> <- Victim gets poisoned response!Common Attack Vectors
Unkeyed Headers
Headers like X-Forwarded-Host, X-Forwarded-Proto, or X-Original-URL often influence the response but aren't in the cache key:
// Vulnerable: Using X-Forwarded-Host for asset URLs
app.get('/page', (req, res) => {
const host = req.headers['x-forwarded-host'] || req.hostname;
res.send(`
<html>
<head>
<link rel="stylesheet" href="https://${host}/styles.css">
<script src="https://${host}/app.js"></script>
</head>
<body>...</body>
</html>
`);
});
// Attack:
// GET /page
// X-Forwarded-Host: attacker.com
//
// Response includes:
// <script src="https://attacker.com/app.js"></script>
// This response gets cached and served to all usersCache Key Normalization
# The server treats these as different:
/page
/page?
/page?utm_source=google
# But the cache might normalize them all to the same key!
# Attack: Find a parameter the server reflects but cache ignores
GET /page?callback=<script>alert(1)</script>
# Server reflects the callback, cache stores with key "/page"
# All visitors to /page now get XSSFat GET Requests
# Some servers process request bodies on GET requests
# Caches typically ignore GET request bodies
GET /api/data HTTP/1.1
Host: example.com
Content-Type: application/json
{"user": "admin", "role": "superuser"}
# Cache key: "GET /api/data, Host: example.com"
# But server processes the body and returns admin data
# All GET requests to /api/data now return admin infoCache-Poisoned Denial of Service (CPDoS)
CPDoS attacks trick the origin server into returning an error response that gets cached, causing DoS for all users:
HTTP Header Oversize (HHO)
# CDN accepts 20KB headers, origin only accepts 8KB
GET /page HTTP/1.1
Host: example.com
X-Padding: AAAA... (10KB of data)
# CDN forwards to origin
# Origin returns: 400 Bad Request (header too large)
# CDN caches this error response
# All users now get 400 for /pageHTTP Meta Character (HMC)
# Inject control characters that break the origin
GET /page HTTP/1.1
Host: example.com
X-Custom: value\n\r\n\r\n
# Origin might interpret this as request smuggling
# Returns 400 or 500 error
# Error gets cachedTesting for Cache Poisoning
import requests
import random
import string
# Step 1: Find unkeyed inputs
headers_to_test = [
'X-Forwarded-Host',
'X-Forwarded-Proto',
'X-Original-URL',
'X-Rewrite-URL',
'X-Forwarded-Scheme',
'X-Host',
'Forwarded',
'X-Custom-IP-Authorization',
]
base_url = 'https://example.com/page'
for header in headers_to_test:
# Use cache buster to get fresh response
cache_buster = ''.join(random.choices(string.ascii_lowercase, k=8))
url = f'{base_url}?cb={cache_buster}'
# Test if header is reflected in response
test_value = f'test-{header.lower()}'
response = requests.get(url, headers={header: test_value})
if test_value in response.text:
print(f'[VULNERABLE] {header} is reflected!')
print(f' Check if it\'s in cache key by repeating without header')Prevention Strategies
Include Varying Headers in Cache Key
// If you use X-Forwarded-Host, include it in cache key
// Add Vary header to response
app.get('/page', (req, res) => {
const host = req.headers['x-forwarded-host'] || req.hostname;
// Tell cache this response varies by X-Forwarded-Host
res.set('Vary', 'X-Forwarded-Host');
res.send(renderPage(host));
});
// Or configure CDN to include header in cache key
// Cloudflare: Cache Rules -> "Cache Key" configuration
// Fastly: Vary header or custom cache keysValidate and Allowlist Headers
const ALLOWED_HOSTS = ['example.com', 'www.example.com', 'cdn.example.com'];
app.get('/page', (req, res) => {
const forwardedHost = req.headers['x-forwarded-host'];
// Validate against allowlist
let host = req.hostname;
if (forwardedHost && ALLOWED_HOSTS.includes(forwardedHost)) {
host = forwardedHost;
}
res.send(renderPage(host));
});
// Better: Strip unknown headers at the edge (CDN/proxy config)Disable Caching for Dynamic Content
// Don't cache responses that include user-specific or dynamic content
app.get('/user/profile', (req, res) => {
// Prevent caching of user-specific pages
res.set({
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.send(renderProfile(req.user));
});
// For truly static content, be explicit
app.get('/static/:file', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=31536000, immutable'
});
res.sendFile(req.params.file);
});CDN Configuration for CPDoS Prevention
# Cloudflare: Don't cache error responses
# Page Rules or Cache Rules:
# - Cache Level: Standard (only caches 200, 301, etc.)
# - Disable caching for error pages
# Fastly VCL:
if (beresp.status >= 400) {
set beresp.uncacheable = true;
return (pass);
}
# Nginx:
proxy_cache_valid 200 301 302 1h;
# Don't add: proxy_cache_valid any 1h; <- This would cache errors!Security Checklist
- Audit all headers that influence response content
- Add varying headers to cache key or Vary response header
- Validate and allowlist X-Forwarded-* headers
- Configure CDN to not cache error responses
- Set explicit Cache-Control headers on all responses
- Reject GET requests with bodies at the edge
- Align header size limits between CDN and origin
- Test cache behavior with Param Miner or similar tools