症状
泄漏有硬性复现边界:经 CDN 域名,用户 B 有时收到用户 A 的缓存响应;直连源站永远正确。响应头解释了一切——个性化端点返回 x-cache: HIT 且 age: 41,说明缓存键不含凭证维度。再问源站声明了什么:/api/me 若完全没有 Cache-Control 头,下游缓存可按启发式规则存储它——bug 从缺失的头开始,CDN 只是执行者。登出后仍收到缓存个性化内容、短暂看到他人数据,是同一机制的两种外显。
诊断思路
先用两个不同 token 打同一 URL 看 x-cache/age:第二个 HIT 即凭证不在缓存键或变体选择里;再直查源站响应头,认证响应缺 Cache-Control 就是起点;最后审计所有返回按用户内容的 handler,逐个补策略。登出响应复用已认证 URL 而不失效缓存变体,是收尾遗漏。
根因
HTTP 缓存存储“响应允许它存储的内容”。认证内容没有显式 Cache-Control 时,共享缓存可能应用启发式缓存,把一个用户的个性化响应发给请求同一 URL 的另一个用户——缓存键漏掉 Authorization、或 Vary 不含凭证维度时尤甚。
修复与验证
源站显式声明策略:每用户内容一律 Cache-Control: private, no-store;真正全网一致的目录数据才 public, max-age。CDN 侧对带凭证路径要么把 Authorization 纳入缓存键、要么存在即旁路缓存;合法的认证变体用 Vary: Authorization 显式化——但对凭证维度的 Vary 要保持警惕,中间件历来实现潦草,敏感载荷的默认答案永远是 no-store。验证时两个会话请求个性化端点各自拿到自己的数据且缓存状态为 MISS 或旁路;登出响应带 no-store 且永不被重放。private 加长 max-age 在共享机器上仍会经浏览器缓存跨账号泄漏——敏感内容用 no-store。
代码示例
$ curl -sI https://app.example.com/api/me -H "Authorization: Bearer bob"
HTTP/2 200
content-type: application/json
x-cache: HIT <- cached variant served to bob
age: 41
app.get('/api/me', auth, (req, res) => {
res.set('Cache-Control', 'private, no-store'); // per-user: never shared
res.json(req.user);
});
app.get('/api/catalog', (req, res) => {
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
res.json(catalog); // genuinely identical for everyone
});
cache key: scheme + host + path + query + $http_authorization
# or simpler and safer: bypass cache when Authorization is present