一次 CloudFront 缓存配置错误导致的用户数据泄露事故
记录一次由于 CDN 缓存配置不当,导致不同用户看到彼此私有数据的严重安全问题。
🔥 事故的开端
那是一个平常的下午,QA 同学在测试环境发现了一个诡异的现象:
"为什么我的会话列表显示的是别人的数据?"
起初我以为是前端缓存的问题,让他清除浏览器缓存试试。但是清除后问题依旧。更奇怪的是,当我用 curl 测试同一个接口时,返回的却是正确的数据。
# curl 请求 - 返回正确数据
curl 'https://example.com/api/user/sessions' \
-H 'authorization: Bearer TOKEN_A'
# 返回:用户 A 的数据(15 条会话)
// 浏览器 fetch - 返回错误数据
fetch('https://example.com/api/user/sessions', {
headers: { 'authorization': 'Bearer TOKEN_A' }
})
// 返回:用户 B 的数据(23 条会话)❌
这下我意识到,问题可能没那么简单。
🕵️ 开始排查
第一步:检查响应头
我首先查看了浏览器请求的响应头:
HTTP/2 200
cache-control: (null) ⚠️
x-cache: Hit from cloudfront ⚠️
age: 1545 ⚠️
via: 1.1 xxx.cloudfront.net (CloudFront)
看到这三个关键信息,我心里一凉:
-
cache-control 为空 - 后端没有设置缓存控制策略
-
x-cache: Hit from cloudfront - 请求命中了 CloudFront 缓存
-
age: 1545 - 这个缓存已经存在了 1545 秒(约 26 分钟)
问题找到了:CloudFront 把用户的私有数据缓存了!
第二步:验证假设
我用不同的方式测试了几次:
// 测试 1: 不带 Cookie,只带 Authorization
fetch('/api/user/sessions', {
headers: { 'authorization': 'Bearer TOKEN_A' },
credentials: 'omit'
})
// 结果:返回用户 B 的数据(缓存)
// 测试 2: 带上 Cookie
fetch('/api/user/sessions', {
headers: { 'authorization': 'Bearer TOKEN_A' },
credentials: 'include'
})
// 结果:还是用户 B 的数据(缓存)
// 测试 3: 添加随机参数绕过缓存
fetch(`/api/user/sessions?_t=${Date.now()}`, {
headers: { 'authorization': 'Bearer TOKEN_A' }
})
// 结果:返回正确的用户 A 数据 ✓
第三个测试证实了我的猜测:CloudFront 缓存键中没有包含 Authorization header!
💡 问题根源分析
我们的系统架构
用户浏览器
↓ 请求: /api/user/sessions
↓ Header: Authorization: Bearer <token>
CloudFront CDN (全球边缘节点)
↓ 缓存键 = 域名 + URL 路径
↓ ⚠️ 不包含 Authorization header!
前端服务器
↓ Rewrite: /api/* → backend-api.com/*
后端 API 服务器
↓ 根据 Authorization 识别用户
↓ 返回对应用户的数据
CloudFront 默认缓存行为
CloudFront 默认的缓存键由以下部分组成:
缓存键 = 域名 + URL 路径 + 查询参数
不包含:
-
❌ 请求头(包括 Authorization)
-
❌ Cookie(除非显式配置)
-
❌ 请求体
问题是如何发生的?
让我用时间线来还原整个过程:
10:00 - 用户 A 登录,token_A
10:01 - 用户 A 请求 /api/user/sessions
→ CloudFront: 缓存 Miss
→ 转发到后端,带上 Authorization: Bearer token_A
→ 后端识别用户 A,返回用户 A 的数据
→ CloudFront 缓存这个响应(缓存键 = /api/user/sessions)
10:15 - 用户 B 登录,token_B
10:16 - 用户 B 请求 /api/user/sessions
→ CloudFront: 缓存 Hit! (因为 URL 相同)
→ 直接返回缓存(用户 A 的数据)❌
→ 请求根本没有到达后端
→ 后端无法识别 token_B
这就是问题的本质:不同用户请求同一个 URL,CloudFront 认为它们是同一个请求,直接返回了缓存的内容。
🤔 为什么 curl 返回正确数据?
这是一个有趣的问题。可能的原因:
-
请求头差异:curl 和浏览器的请求头不同,可能形成了不同的缓存键
-
时机问题:curl 测试时缓存可能已经过期
-
Vary 响应头:如果后端设置了某些 Vary 头,curl 可能命中了不同的缓存
但无论如何,这都说明了缓存策略的不确定性和危险性。
🔧 解决方案探索
方案对比
我考虑了三个方案:
方案 1:修改 CloudFront Cache Policy,将 Authorization 加入缓存键
想法:让每个 token 有独立的缓存。
问题:
用户 A (token_A) → 缓存 A
用户 B (token_B) → 缓存 B
...
-
❌ 用户数据仍然会被缓存在 CDN 边缘节点(安全风险)
-
❌ 缓存数量爆炸(每个用户一份缓存)
-
❌ 如果 token 刷新,缓存会失效,浪费资源
-
❌ 需要修改 CloudFront 配置(由于我们有前端 rewrite,很容易配错导致 502)
结论:不推荐
方案 2:在 CloudFront 禁用 /api/* 路径的缓存
想法:直接禁用 API 路径的缓存。
问题:
-
⚠️ 由于我们的架构(前端服务器做了 rewrite),配置
/api/*的 Cache Behavior 会绕过前端服务器,直接转发到后端 -
💥 后端没有
/api/*这个路径,导致 502 错误(我们实际遇到了这个问题)
结论:不适用于我们的架构
方案 3:在后端设置 Cache-Control 响应头 ⭐
想法:让后端明确告诉 CloudFront:"这个响应不要缓存!"
Cache-Control: private, no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
优点:
-
✅ 最根本的解决方案
-
✅ 不需要修改 CloudFront 配置
-
✅ 对所有中间层(CDN、代理、浏览器)都生效
-
✅ 符合 HTTP 标准和最佳实践
结论:采用!
🛠️ 具体实现
1. 后端添加中间件
我们使用 Go + Gin 框架,实现很简单:
// middleware/nocache.go
package middleware
import "github.com/gin-gonic/gin"
func NoCacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "private, no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Next()
}
}
在路由中应用:
// router/router.go
func SetupRouter() *gin.Engine {
r := gin.Default()
// 对所有需要身份验证的路由应用
authRoutes := r.Group("/")
authRoutes.Use(middleware.AuthMiddleware())
authRoutes.Use(middleware.NoCacheMiddleware()) // 关键!
{
authRoutes.GET("/user/sessions", handlers.GetUserSessions)
authRoutes.GET("/user/quota", handlers.GetUserQuota)
authRoutes.GET("/user/profile", handlers.GetUserProfile)
}
return r
}
2. 清除 CloudFront 现有缓存
代码部署后,还需要清除已经存在的缓存:
# AWS CLI 方式
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/*"
# 或者在 AWS Console 中操作:
# CloudFront → Distributions → Invalidations → Create
# Path: /*
3. 验证修复
# 等待 5-10 分钟后测试
curl -I 'https://example.com/api/user/sessions' \
-H 'authorization: Bearer YOUR_TOKEN'
期望看到:
HTTP/2 200
cache-control: private, no-cache, no-store, must-revalidate ✓
pragma: no-cache ✓
expires: 0 ✓
x-cache: Miss from cloudfront ✓
age: (不存在) ✓
多次请求应该都是 Miss from cloudfront,而不是 Hit。
📚 深入理解 Cache-Control
这次事故让我重新审视了 HTTP 缓存机制。让我详细解释一下这些响应头:
Cache-Control 指令详解
Cache-Control: private, no-cache, no-store, must-revalidate
-
private:响应只能被浏览器缓存,不能被 CDN 等共享缓存存储 -
即使后续有人设置了代理缓存,也不会缓存这个响应
-
no-cache:可以缓存,但使用前必须向服务器验证 -
看起来矛盾?其实是"not without revalidation"的意思
-
主要用于需要实时验证但允许存储的场景
-
no-store:完全不缓存,任何情况下都不存储 -
最严格的禁止缓存指令
-
用于高度敏感的数据
-
must-revalidate:缓存过期后必须向服务器验证,不能使用过期内容 -
防止在网络故障时使用过期缓存
Pragma 和 Expires
Pragma: no-cache
Expires: 0
这两个是为了兼容 HTTP/1.0 的老旧客户端和代理服务器。虽然现在大多数系统都支持 HTTP/1.1,但加上它们没有坏处。
为什么需要这么多指令?
因为缓存层级很多:
浏览器缓存
↓
正向代理(公司代理)
↓
CDN 边缘节点
↓
CDN 中心节点
↓
反向代理(Nginx)
↓
后端服务器
每一层都可能缓存,所以需要明确告诉它们:"这个响应不要缓存!"
🎯 经验教训
1. 用户数据永远不要被 CDN 缓存
原则:需要身份验证的接口 = 私有数据 = 禁止缓存
即使你认为"缓存可以提高性能",也不要这样做。因为:
-
用户数据随时可能变化
-
Token 可能被刷新或撤销
-
存在数据泄露风险
-
可能违反数据保护法规(GDPR、CCPA)
2. 明确区分可缓存和不可缓存的内容
在设计 API 时,就应该考虑缓存策略:
✓ 可以缓存:
/public/articles - 公开文章列表
/static/logo.png - 静态资源
/api/public/config - 公开配置
✗ 禁止缓存:
/api/user/* - 用户数据
/api/auth/* - 认证相关
/api/admin/* - 管理接口
3. 不要依赖 CDN 的默认行为
CloudFront 的默认缓存策略是:
-
对 GET/HEAD 请求缓存成功的响应(200、301、404 等)
-
缓存键只包含 URL 和查询参数
这对静态资源很好,但对 API 可能是灾难。
4. 后端要对响应负责
不要期望运维或前端来配置正确的缓存策略。后端应该明确告诉客户端和中间层如何处理响应:
// 好的做法:创建统一的响应函数
func RespondJSON(c *gin.Context, code int, data interface{}) {
// 自动添加安全的响应头
c.Header("Cache-Control", "private, no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.JSON(code, data)
}
5. 测试要覆盖缓存场景
在测试用例中应该包含:
// test/api-cache.test.js
describe('API Caching', () => {
it('should not cache user data', async () => {
// 第一次请求
const res1 = await fetch('/api/user/sessions', {
headers: { authorization: `Bearer ${token1}` }
});
const data1 = await res1.json();
// 第二次请求(不同用户)
const res2 = await fetch('/api/user/sessions', {
headers: { authorization: `Bearer ${token2}` }
});
const data2 = await res2.json();
// 验证返回的是不同用户的数据
expect(data1.userId).not.toBe(data2.userId);
// 验证响应头
expect(res1.headers.get('cache-control')).toContain('no-cache');
expect(res2.headers.get('cache-control')).toContain('no-cache');
});
it('should not be cached by CDN', async () => {
const res = await fetch('/api/user/sessions', {
headers: { authorization: `Bearer ${token}` }
});
// 验证没有命中 CDN 缓存
expect(res.headers.get('x-cache')).toBe('Miss from cloudfront');
expect(res.headers.get('age')).toBeNull();
});
});
🔍 排查技巧分享
如何快速判断是否是缓存问题?
1. 查看响应头
curl -I 'https://example.com/api/user/sessions' \
-H 'authorization: Bearer TOKEN'
关键字段:
-
x-cache: Hit- 命中缓存 -
age: 数字- 缓存存在的时间 -
cache-control: null- 没有缓存策略(危险!)
2. 添加随机参数测试
// 如果这个返回正确数据,说明是缓存问题
fetch(`/api/user/sessions?_t=${Date.now()}`)
3. 使用不同的客户端测试
-
浏览器 → 可能命中缓存
-
curl → 可能绕过缓存
-
Postman → 可能有独立的缓存
如果不同客户端返回不同的数据,很可能是缓存问题。
使用 Chrome DevTools 检查
-
打开 Network 标签
-
刷新页面
-
点击对应的请求
-
查看 Response Headers
重点关注:
-
CF-Cache-Status: HIT(Cloudflare) -
X-Cache: Hit from cloudfront(CloudFront) -
X-Cache-Lookup: HIT from xxx(其他 CDN)
🛡️ 预防措施
1. 建立监控告警
监控指标:
// CloudWatch Logs Insights 查询
fields @timestamp, request_path, cache_status
| filter cache_status = "Hit"
and request_path like /^\/api\//
and request_path not like /^\/api\/public\//
| stats count() by request_path
告警规则:
-
如果私有 API 出现缓存命中 → 立即告警
-
如果 API 响应缺少
Cache-Control头 → 告警
2. 代码审查检查项
在 Code Review 时,检查:
-
新的 API endpoint 是否添加了 no-cache 响应头?
-
是否使用了统一的响应函数?
-
是否有测试覆盖缓存场景?
3. 架构层面的防护
前端服务器添加安全响应头:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'private, no-cache, no-store, must-revalidate',
},
],
},
];
},
};
这样即使后端忘记设置,前端也会添加安全的响应头(双重保险)。
4. 定期安全审计
每个季度检查:
-
哪些 API 可以被缓存?
-
缓存的内容是否包含敏感信息?
-
CDN 配置是否正确?
-
是否有新的 API 没有设置缓存策略?
📊 影响分析
这次事故的影响范围:
受影响的接口
我们排查了所有 API,发现以下接口都有同样的问题:
✓ 已修复:
/api/user/sessions - 用户会话列表
/api/user/quota - 用户配额
/api/user/profile - 用户资料
/api/user/settings - 用户设置
潜在的数据泄露
-
时间窗口:从部署到发现,约 2 周
-
受影响用户:所有活跃用户
-
泄露数据类型:会话信息、配额使用情况、用户设置
-
严重程度:高(虽然没有密码等核心数据,但仍然是隐私信息)
合规性问题
根据 GDPR 和 CCPA:
-
用户数据被缓存在第三方服务器(CloudFront 边缘节点)
-
用户没有被告知数据会被如此处理
-
存在未经授权的数据访问
幸运的是,我们在测试环境发现了这个问题,没有影响到生产环境。
💭 反思
为什么会犯这个错误?
- 对 CDN 的理解不够深入
-
我们知道 CDN 会缓存,但没想到会缓存 API 响应
-
以为"有 Authorization header 就不会缓存"
- 缺少完整的测试
-
功能测试只关注"能否返回正确数据"
-
没有测试"不同用户是否会看到彼此的数据"
- 架构变更时没有充分评估
-
引入 CloudFront 时,只考虑了性能提升
-
没有评估对 API 的影响
- 缺少安全意识
-
没有建立"默认禁止缓存私有数据"的原则
-
没有代码规范要求必须设置 Cache-Control
如果重来一次,我会怎么做?
- 在架构设计阶段就明确缓存策略
静态资源 → CDN 缓存
公开 API → CDN 缓存(短时间)
私有 API → 禁止缓存
- 建立统一的响应规范
-
创建标准的响应函数
-
自动添加正确的响应头
-
不允许手动设置响应头
- 完善测试覆盖
-
添加缓存测试用例
-
添加跨用户数据隔离测试
-
在 CI/CD 中检查响应头
- 部署前的安全审计
-
检查所有新的 API endpoint
-
验证响应头是否正确
-
使用不同用户 token 测试
🎓 拓展阅读
关于 HTTP 缓存
关于 CloudFront
关于 Web 安全
✨ 总结
这次事故给我上了深刻的一课:
1. CDN 是双刃剑
-
能提升性能,但配置不当会导致严重的安全问题
-
理解 CDN 的缓存机制是必须的
2. 后端要明确告诉客户端如何处理响应
-
不要依赖默认行为
-
使用
Cache-Control明确控制缓存策略
3. 安全测试很重要
-
功能测试 + 安全测试
-
不同用户的数据隔离测试
4. 建立防护机制
-
监控告警
-
代码规范
-
自动化检查
核心原则
需要身份验证的 API,永远不要被 CDN 缓存。
如果你的系统也使用了 CloudFront、Cloudflare 或其他 CDN,建议立即检查一下你的 API 响应头。方法很简单:
curl -I 'https://your-api.com/api/user/data' \
-H 'authorization: Bearer YOUR_TOKEN' \
| grep -i cache
如果看到 x-cache: Hit 或者 cache-control: null,那你可能也面临同样的问题。
🙏 致谢
感谢 QA 团队发现了这个问题,感谢团队快速响应和修复。这次事故虽然是在测试环境发现的,但也提醒我们:
再小的问题也可能导致严重的后果,安全无小事。
你遇到过类似的缓存问题吗?欢迎在评论区分享你的经验。
本文技术栈:CloudFront + Next.js + Go,但原理适用于所有 CDN + API 的架构。