Back to Learning Center
highOWASP Cache PoisoningCWE-349

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.

text
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:

javascript
// 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 users

Cache Key Normalization

text
# 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 XSS

Fat GET Requests

text
# 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 info

Cache-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)

text
# 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 /page

HTTP Meta Character (HMC)

text
# 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 cached

Testing for Cache Poisoning

python
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

javascript
// 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 keys

Validate and Allowlist Headers

javascript
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

javascript
// 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

text
# 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