Security Reference
MCP server cache poisoning: HTTP cache deception and CDN poisoning defense
Cache poisoning turns a caching layer — CDN, reverse proxy, or in-process cache — into a delivery mechanism for attacker-controlled responses. In the MCP context, a poisoned cache entry can cause every Claude Code session to receive a malicious tool description, altered schema, or forged authentication bypass.
How cache poisoning applies to MCP servers
MCP servers typically run over HTTP transport. Responses — tool listings, schema definitions, resource payloads — can be cached at multiple layers: the client's in-process cache, a reverse proxy (nginx, Caddy), or a CDN in front of a hosted server. Cache poisoning exploits mismatches between what the cache key covers and what actually affects the response.
The classic attack: attacker injects a header that's reflected into the response body but not included in the cache key. The cache stores the attacker-influenced response under the victim's cache key. Every subsequent user who fetches that key gets the poisoned response until TTL expires or the cache is purged.
Attack vector 1: unkeyed request headers
HTTP caches typically key on URL + method + a subset of headers (usually Accept-Encoding, sometimes Accept). Headers like X-Forwarded-Host, X-Original-URL, and X-Rewrite-URL are often forwarded to the origin but not included in the cache key. If your MCP server reflects them in responses:
// VULNERABLE: reflecting host header into absolute URLs in response
app.get('/mcp/tools', (req, res) => {
const host = req.headers['x-forwarded-host'] || req.headers.host;
res.json({
tools: TOOL_LIST.map(tool => ({
...tool,
// Attacker can inject host → response cached with attacker domain
schema_url: `https://${host}/mcp/schemas/${tool.name}`
}))
});
});
// SAFE: use a configured base URL, never request-derived
const BASE_URL = process.env.MCP_BASE_URL || 'https://mcp.myserver.com';
app.get('/mcp/tools', (req, res) => {
res.json({
tools: TOOL_LIST.map(tool => ({
...tool,
schema_url: `${BASE_URL}/mcp/schemas/${tool.name}`
}))
});
});
Attack vector 2: CDN cache deception via URL normalization
CDNs cache responses for paths matching patterns like *.json or /static/**. The attack appends a path suffix the CDN will cache but the origin ignores:
# CDN caches responses for /static/** but strips the suffix before forwarding
# Attacker fetches: /mcp/session-data/sensitive-config.json
# CDN sees .json extension → caches it
# Origin sees: /mcp/session-data (ignores /sensitive-config.json)
# Attacker now has a cached copy of authenticated session data
# Next victim who fetches /mcp/session-data/sensitive-config.json gets the cached copy
# Defense: explicit Cache-Control headers on all authenticated responses
app.use('/mcp', (req, res, next) => {
res.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
'Pragma': 'no-cache',
'Vary': 'Authorization, Cookie' // if cached, key on auth headers
});
next();
});
Attack vector 3: Web Cache Deception (WCD)
WCD is a variant where the attacker tricks a victim (not the server) into making a cacheable request for a sensitive endpoint. The cached response is then retrieved by the attacker:
// 1. Attacker constructs URL: https://yourserver.com/mcp/user-profile/fake.css
// 2. Victim (with valid session) is tricked into visiting this URL
// 3. CDN sees .css → caches the response
// 4. Response is /mcp/user-profile (victim's authenticated data)
// 5. Attacker fetches /mcp/user-profile/fake.css → gets victim's profile
// Defense: strict path routing — reject unknown suffixes on API routes
app.get('/mcp/user-profile', authMiddleware, (req, res) => {
// Must match exactly — no suffix tolerance
if (req.path !== '/mcp/user-profile') {
return res.status(404).json({ error: 'not_found' });
}
res.set('Cache-Control', 'no-store, private');
res.json(getUserProfile(req.user.id));
});
// Or express route matching — the above is guaranteed by exact-path GET
In-process cache poisoning via tool inputs
MCP servers often cache expensive computations (audit results, schema lookups, external API responses) keyed on tool input values. An attacker who controls tool input values can poison the cache for other users:
// VULNERABLE: cache key derived from unsanitized user input
const cache = new Map();
server.tool('getSchema', { repo: z.string() }, async ({ repo }) => {
if (cache.has(repo)) return cache.get(repo); // cache hit — serves to all users
const schema = await fetchRepoSchema(repo);
cache.set(repo, schema); // attacker's response stored for victim key
return schema;
});
// SAFE: normalize and validate the cache key; use per-user caches for sensitive data
const REPO_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/;
server.tool('getSchema', { repo: z.string().url() }, async ({ repo, _ctx }) => {
if (!REPO_RE.test(repo)) throw new McpError(ErrorCode.InvalidParams, 'Invalid repo URL');
const userId = _ctx?.session?.userId ?? 'anon';
const key = `${userId}:${repo}`; // per-user namespace
if (cache.has(key)) return cache.get(key);
const schema = await fetchRepoSchema(repo);
cache.set(key, schema);
return schema;
});
Vary header requirements
When a response legitimately varies by request headers (authorization, content negotiation), the Vary header tells caches which headers to include in the cache key:
// Missing Vary = cache may serve wrong response variant to different clients
// VULNERABLE: no Vary header, but response content depends on auth
app.get('/mcp/audit-results', (req, res) => {
const results = getAuditResults(req.user.orgId); // varies by auth
res.json(results); // Vary header absent — CDN may serve org A's data to org B
});
// SAFE: declare all headers the response varies on
app.get('/mcp/audit-results', (req, res) => {
const results = getAuditResults(req.user.orgId);
res.set({
'Cache-Control': 'private, max-age=300',
'Vary': 'Authorization' // distinct cache entry per auth header value
});
res.json(results);
});
CDN behavior warning: Some CDNs strip or ignore Vary: Cookie and Vary: Authorization for performance reasons. Verify your CDN's Vary handling before relying on it for authentication-keyed caches. The safest policy for authenticated MCP endpoints is Cache-Control: no-store, private.
SkillAudit grading criteria
| Finding | Severity | Score impact |
|---|---|---|
| Unkeyed request header reflected in response body | HIGH | −20 |
| Authenticated endpoint missing Cache-Control: private or no-store | HIGH | −15 |
| In-process cache keyed on unsanitized user input | MEDIUM | −10 |
| Missing Vary header on content-negotiated response | MEDIUM | −8 |
| CDN cache rules match API paths (no explicit bypass) | MEDIUM | −8 |
| Explicit no-store on all authenticated MCP routes | PASS | +5 |
| Per-user cache namespace for shared in-process cache | PASS | +5 |
Related SkillAudit checks
- SSRF security — cache poisoning can amplify SSRF by caching internal network responses
- HTTP header injection — CRLF injection can introduce forged headers that bypass cache key normalization
- Input validation patterns — cache key sanitization is a form of semantic validation
- Rate limiting — attackers testing cache poisoning targets generate above-baseline request volumes