一次 CloudFront 缓存配置错误导致的用户数据泄露事故

5 阅读6分钟

一次 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)

看到这三个关键信息,我心里一凉:

  1. cache-control 为空 - 后端没有设置缓存控制策略

  2. x-cache: Hit from cloudfront - 请求命中了 CloudFront 缓存

  3. 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 返回正确数据?

这是一个有趣的问题。可能的原因:

  1. 请求头差异:curl 和浏览器的请求头不同,可能形成了不同的缓存键

  2. 时机问题:curl 测试时缓存可能已经过期

  3. 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 检查

  1. 打开 Network 标签

  2. 刷新页面

  3. 点击对应的请求

  4. 查看 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 边缘节点)

  • 用户没有被告知数据会被如此处理

  • 存在未经授权的数据访问

幸运的是,我们在测试环境发现了这个问题,没有影响到生产环境。


💭 反思

为什么会犯这个错误?

  1. 对 CDN 的理解不够深入
  • 我们知道 CDN 会缓存,但没想到会缓存 API 响应

  • 以为"有 Authorization header 就不会缓存"

  1. 缺少完整的测试
  • 功能测试只关注"能否返回正确数据"

  • 没有测试"不同用户是否会看到彼此的数据"

  1. 架构变更时没有充分评估
  • 引入 CloudFront 时,只考虑了性能提升

  • 没有评估对 API 的影响

  1. 缺少安全意识
  • 没有建立"默认禁止缓存私有数据"的原则

  • 没有代码规范要求必须设置 Cache-Control

如果重来一次,我会怎么做?

  1. 在架构设计阶段就明确缓存策略

静态资源 → CDN 缓存

公开 API → CDN 缓存(短时间)

私有 API → 禁止缓存

  1. 建立统一的响应规范
  • 创建标准的响应函数

  • 自动添加正确的响应头

  • 不允许手动设置响应头

  1. 完善测试覆盖
  • 添加缓存测试用例

  • 添加跨用户数据隔离测试

  • 在 CI/CD 中检查响应头

  1. 部署前的安全审计
  • 检查所有新的 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 的架构。