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-Host and X-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

  1. Do not cache authenticated responses. Cache-Control: no-store as the default removes most of the surface.
  2. Do not use request headers to build responses. Absolute URLs come from configuration.
  3. Include every response-affecting input in the cache key, or strip it at the edge.
  4. Cache by extension only when the content type agrees — a .css URL returning application/json should never be cached.
  5. Normalise paths at the edge so /api/me/x.css does not reach an endpoint that ignores the suffix.

Example

Example · bash
# 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 data

When 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.

Example · javascript
// ❌ 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.

Example · bash
# 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 404

Probing 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.

Example · bash
#!/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

  • Be the first to comment on this lesson.