Nginx proxy_cache 忽略 Set-Cookie / Cache-Control 导致用户会话串号、越权(无 Cookie 请求命中他人缓存)
日期:2026-09-13 16:00 | 第 53 题 | 分支:
2026-09-13-1600技术栈 / 关键词:Nginx、proxy_cache、proxy_ignore_headers、Set-Cookie、Cache-Control、 no-store、private、会话串号、用户数据越权、缓存投毒、cache poisoning、个人信息泄漏、 proxy_cache_key、$cookie_sessionid、proxy_no_cache、proxy_cache_bypass、X-Cache-Status、 $upstream_cache_status、共享缓存隔离、反向代理缓存安全、Nginx 缓存私有接口 环境:Docker(nginx:1.27-alpine + node:22-alpine + Express),无需特权
一、问题背景
一个内部 SaaS 系统:浏览器 → Nginx(反向代理 + 缓存加速) → Node.js/Express 后端 API。
运维为了「给 API 加速」,给 /api/ 开了代理缓存:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m inactive=60m max_size=1g;
location /api/ {
proxy_pass http://app:3000;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_ignore_headers Set-Cookie; # ← 运维「加个保险」
proxy_ignore_headers Cache-Control; # ← 同上
add_header X-Cache-Status $upstream_cache_status;
}
上线后接到诡异投诉,且只在高峰期出现、重启 Nginx 后好一阵又复发:
- 看到别人的数据:A 打开「我的资料」,返回的却是 B 的姓名、手机号、内部密钥;
- 登录状态莫名变了:后端新生成的
Set-Cookie在响应里看不到,浏览器拿不到新会话,刷新后还是老身份; - 越刷新越错:清浏览器缓存、换浏览器、换电脑都无效,同一个人一会儿是自己一会儿是别人;
- 后端日志对不上:用户看到别人数据,但后端访问日志里根本没有这次请求。
/api/ 下既有公开接口(/api/public/dict),也有私有接口(/api/profile),运维图省事一起缓存了。
二、复现步骤
2.1 拉起环境
git fetch origin
git checkout 2026-09-13-1600
cd scenario/2026-09-13-1600
docker compose up -d --build # nginx 暴露 8080,app 只在内网 3000
docker compose ps # scen53-nginx / scen53-app 均 healthy
2.2 一键复现
bash test.sh
修复前输出(关键部分):
【第 1 步】用户 A 首次访问 /api/profile(无 Cookie)
HTTP/1.1 200 OK
Set-Cookie: sessionid=user_1; Path=/; HttpOnly
X-Cache-Status: MISS
{"status":"ok","user":{"id":"user_1","secret":"1C2C2D210F07EA3A",...}}
【第 2 步】用户 B 首次访问 /api/profile(另一个浏览器,无 Cookie)
HTTP/1.1 200 OK
Set-Cookie: sessionid=user_1; Path=/; HttpOnly ← 还是 A 的会话
X-Cache-Status: HIT ← 直接命中缓存
{"status":"ok","user":{"id":"user_1","secret":"1C2C2D210F07EA3A",...}} ← A 的密钥!
【第 3 步】用户 C 同上,仍是 user_1 / HIT
【第 6 步】后端日志
scen53-app | [APP] NEW session user_1 ← 全程只有 1 条 profile 记录
== 判定 ==
>>> ❌ 复现成功:不同"新用户"拿到了同一个会话 → 会话串号 / 数据越权
2.3 手工复现(三条 curl)
# 1) 无 Cookie 新用户
curl -s -i -H 'Cookie:' http://localhost:8080/api/profile
# 2) 另一个无 Cookie 新用户(本该拿到自己的新会话)
curl -s -i -H 'Cookie:' http://localhost:8080/api/profile
# 3) 带着别人的 Cookie
curl -s -i -H 'Cookie: sessionid=user_999' http://localhost:8080/api/profile
# 观察 X-Cache-Status / Set-Cookie / user.id / user.secret
2.4 取证命令(这些是排查这类问题的关键)
# 后端到底收到几次请求?—— 只有 1 次
docker compose logs app | grep '\[APP\]'
# Nginx 视角:第 2、3 次是 cache=HIT,请求没转发到上游
docker compose logs nginx | grep '"GET /api/profile'
# "GET /api/profile HTTP/1.1" 200 144 "-" "curl" cache=MISS
# "GET /api/profile HTTP/1.1" 200 144 "-" "curl" cache=HIT
# "GET /api/profile HTTP/1.1" 200 144 "-" "curl" cache=HIT
# 缓存对象落地,且头部能直接读出 cache key(不含会话维度!)
docker compose exec nginx sh -c 'find /var/cache/nginx -type f'
docker compose exec nginx sh -c 'head -c 200 $(find /var/cache/nginx -type f | head -1)'
# KEY: httpGET127.0.0.1/api/public/dict ← key = scheme+method+host+uri
三、根本原因
3.1 proxy_ignore_headers Set-Cookie 关掉了 Nginx 的内置安全保护
Nginx 默认会「因为响应带 Set-Cookie 而拒绝缓存」——这是防串号的内置底座。
proxy_ignore_headers Set-Cookie 显式废掉这道保护,于是:
- 带
Set-Cookie的私有响应 被允许写入共享缓存; - 缓存命中时 Set-Cookie 被丢弃,客户端永远拿不到会话 → 永远算「新用户」→ 越刷越错。
3.2 proxy_ignore_headers Cache-Control 让源站的「别缓存我」失效
后端已正确声明:
Cache-Control: no-store, no-cache, private, max-age=0
Pragma: no-cache
Nginx 默认遵守它(no-store → 不存储)。但这行配置让 Nginx 无视该指令,
只按 proxy_cache_valid 200 10m 把别人的私有数据缓存了 10 分钟。
3.3 cache key 不含会话维度 → 所有人共享一份缓存
proxy_cache_key "$scheme$request_method$host$request_uri";
对 /api/profile,任何用户算出的 key 都是 httpGET127.0.0.1/api/profile(实测在缓存文件头部可见)。
缓存是「按 URL 共享」的,而私有响应是「按会话独立」的。 语义冲突时不区分会话,必然串号。
3.4 逐条解释现场现象
| 现象 | 机制 |
|---|---|
| 用户看到别人数据 | 缓存击穿会话边界:A 的私有响应被当作可缓存的共享对象,后续请求按同一 URL 命中 |
| 重启 Nginx 后好一阵又犯 | 重启清空缓存,需要「灌」第一份受害者缓存;等第一个用户访问把缓存建立起来,串号重新开始(proxy_cache_valid 200 10m + inactive=60m) |
| 用户说看到别人数据,后端日志没有 | X-Cache-Status: HIT,请求被缓存拦截、未转发给上游。只看应用日志会得出「一切正常」的错误结论 |
| cache key 为什么要带会话 | 私有接口响应随会话变化,key 必须含 $cookie_sessionid / $http_authorization 等身份维度 |
no-store / private 的作用 | 源站对缓存的指令:no-store = 任何缓存都不得存储;private = 只允许浏览器私有缓存,禁止共享缓存。proxy_ignore_headers Cache-Control 会把它废掉 |
3.5 设计层面的问题
/api/ 这个 location 同时混放了可缓存的公开接口和不可缓存的私有接口,
只能按最严格策略处理。缓存粒度要按「响应是否随用户变化」切分,不能按 URL 前缀一刀切。
四、解决办法
修改 nginx/default.conf:
location /api/ {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
- proxy_cache_key "$scheme$request_method$host$request_uri";
- proxy_ignore_headers Set-Cookie;
- proxy_ignore_headers Cache-Control;
+ proxy_cache_key "$scheme$request_method$host$request_uri$cookie_sessionid";
+
+ # 带 Cookie 的请求一律绕过缓存(不读也不写)
+ proxy_cache_bypass $cookie_sessionid;
+ proxy_no_cache $cookie_sessionid;
- add_header X-Cache-Status $upstream_cache_status;
+ add_header X-Cache-Status $upstream_cache_status always;
}
三招核心:
- 删掉
proxy_ignore_headers Set-Cookie与proxy_ignore_headers Cache-Control→ 后端的no-store, private生效(响应永不入缓存),Set-Cookie正常透传。 proxy_cache_bypass $cookie_sessionid+proxy_no_cache $cookie_sessionid→ 只要请求可能带身份,既不读缓存也不写缓存,拒绝缓存投毒(显式兜底)。- 缓存只留给确定可缓存的公开接口
/api/public/;cache key 补$cookie_sessionid作为「真要缓存私有接口」的正确姿势示范。
对真正敏感的接口,最稳妥是 不写
proxy_cache(或proxy_cache off;),从根上不给串号机会。
生产加固建议
# 把上游的 no-store/private 变成显式绕过条件,做双重保险
map $upstream_http_cache_control $skip_cache {
default 0;
"~*no-store" 1;
"~*private" 1;
}
proxy_no_cache $skip_cache$cookie_sessionid;
proxy_cache_bypass $skip_cache$cookie_sessionid;
- 敏感接口的 cache key 再加
$http_authorization。 - 网关统一策略:响应含
Set-Cookie/ 请求含Authorization一律不缓存。 - 加 CI 检查:配置里出现
proxy_ignore_headers就告警/拦截。
改配置后无需重建镜像(bind mount):
docker compose exec nginx nginx -t
docker compose exec nginx sh -c 'rm -rf /var/cache/nginx/*; nginx -s reload'
五、验证
bash test.sh # 结果:
# 【第 1 步】A → MISS + Set-Cookie: sessionid=user_3 → A_ID=user_3
# 【第 2 步】B → MISS + Set-Cookie: sessionid=user_4 → B_ID=user_4
# 【第 3 步】C → MISS + Set-Cookie: sessionid=user_5 → C_ID=user_5
# 【第 4 步】带 A 的 Cookie → X-Cache-Status: BYPASS,返回 user_3 自己的数据
# 【第 5 步】公开接口 → MISS / HIT(缓存收益保留)
# >>> ✅ 三个新用户各自拿到独立会话,未串号
bash verify.sh # 断言式 7 项
# ✅ 5 次请求得到 5 个不同会话
# ✅ Set-Cookie 存在
# ✅ 私有接口 X-Cache-Status=MISS(非 HIT)
# ✅ 带 Cookie 请求 X-Cache-Status=BYPASS
# ✅ 无 Cookie 第二次请求仍是 MISS(缓存投毒回归)
# ✅ public/dict: MISS → HIT(缓存收益保留)
# ✅ 私有接口缓存对象数 = 0
# == 结果: 通过 7 项 / 失败 0 项 ==
其它验证:
# 缓存目录里已无私有接口的对象
docker compose exec nginx sh -c 'grep -rl "/api/profile" /var/cache/nginx | wc -l' # 0
# 并发 20 个无 Cookie 请求,id 去重后无重复
# 全新环境(down -v 后 up --build)再跑 verify.sh → 7/7 通过
# 对照实验:只删 proxy_ignore_headers、保留原 cache key → MISS/MISS/MISS,也不再串号
# → 证实 proxy_ignore_headers 是直接元凶;但显式 $cookie_sessionid 绕过仍是必要的兜底
六、经验与避坑
Set-Cookie是 Nginx 缓存的内置红线:只要用了proxy_ignore_headers Set-Cookie, 就等于亲手拆掉「不缓存带会话响应」的保护。除非你非常清楚后果,否则永远不要写这一行。proxy_ignore_headers Cache-Control同样危险:它让源站的no-store/private失效, 是「后端明明声明了不缓存,却被缓存」的常见原因。- cache key 必须含缓存决策所需的全部维度。响应随会话变 → key 就要含会话
(
$cookie_sessionid);随语言/Accept 变 → 就要含$http_accept_language。 - 缓存粒度按「响应是否随用户变化」切分,不要按 URL 前缀切。公开接口和私有接口
应放在不同的
location。 proxy_cache_bypass+proxy_no_cache是显式保险:不要只依赖上游响应头这个隐式契约。- 排查这类「幽灵请求」必须看代理层指标:
$upstream_cache_status(MISS/HIT/BYPASS/EXPIRED) 与add_header X-Cache-Status ... always。「后端没有日志」不是没问题,而是请求没到后端。 - 两个高频排查坑:
- nginx 官方镜像里
/var/log/nginx/access.log是 指向/dev/stdout的软链接,docker compose exec nginx cat access.log会把命令挂死(超时),请用docker compose logs nginx; - 两轮实验之间必须清缓存,否则会被上一轮的缓存「喂」到而误判。
- nginx 官方镜像里
- 定位利器:缓存文件头部明文可读,直接 dump 就能看到
KEY: httpGEThost/uri, 一眼确认 cache key 是否缺维度。 - 可复用的自检清单:
- 配置里 grep 一下
proxy_ignore_headers,有就重新评估; - 检查
proxy_cache_key是否包含身份维度; - 用「两个无 Cookie 客户端」做冒烟测试,确认拿到不同会话;
- 校验私有接口的
X-Cache-Status永远不是HIT。
- 配置里 grep 一下
相关文件
scenario/2026-09-13-1600/
├── README.md # 题目
├── SOLUTION.md # 完整操作过程记录
├── docker-compose.yml # nginx(8080) + app(3000)
├── test.sh # 复现脚本(修复前 ❌ 串号 / 修复后 ✅)
├── verify.sh # 断言式回归校验(7 项)
├── nginx/default.conf # ★ 修复的文件
└── app/index.js # Express:无 Cookie 建新会话 + Set-Cookie + no-store
一键复现:
git checkout 2026-09-13-1600
cd scenario/2026-09-13-1600
docker compose up -d --build
bash test.sh && bash verify.sh
docker compose down -v
