朕喜技术(ZhenxiJS)
2026.09.13
1 分钟阅读

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 后好一阵又复发:

  1. 看到别人的数据:A 打开「我的资料」,返回的却是 B 的姓名、手机号、内部密钥;
  2. 登录状态莫名变了:后端新生成的 Set-Cookie 在响应里看不到,浏览器拿不到新会话,刷新后还是老身份;
  3. 越刷新越错:清浏览器缓存、换浏览器、换电脑都无效,同一个人一会儿是自己一会儿是别人;
  4. 后端日志对不上:用户看到别人数据,但后端访问日志里根本没有这次请求。

/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

三、根本原因

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;
 }

三招核心:

  1. 删掉 proxy_ignore_headers Set-Cookie 与 proxy_ignore_headers Cache-Control → 后端的 no-store, private 生效(响应永不入缓存),Set-Cookie 正常透传。
  2. proxy_cache_bypass $cookie_sessionid + proxy_no_cache $cookie_sessionid → 只要请求可能带身份,既不读缓存也不写缓存,拒绝缓存投毒(显式兜底)。
  3. 缓存只留给确定可缓存的公开接口 /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 绕过仍是必要的兜底

六、经验与避坑

  1. Set-Cookie 是 Nginx 缓存的内置红线:只要用了 proxy_ignore_headers Set-Cookie, 就等于亲手拆掉「不缓存带会话响应」的保护。除非你非常清楚后果,否则永远不要写这一行。
  2. proxy_ignore_headers Cache-Control 同样危险:它让源站的 no-store / private 失效, 是「后端明明声明了不缓存,却被缓存」的常见原因。
  3. cache key 必须含缓存决策所需的全部维度。响应随会话变 → key 就要含会话 ($cookie_sessionid);随语言/Accept 变 → 就要含 $http_accept_language。
  4. 缓存粒度按「响应是否随用户变化」切分,不要按 URL 前缀切。公开接口和私有接口 应放在不同的 location。
  5. proxy_cache_bypass + proxy_no_cache 是显式保险:不要只依赖上游响应头这个隐式契约。
  6. 排查这类「幽灵请求」必须看代理层指标:$upstream_cache_status(MISS/HIT/BYPASS/EXPIRED) 与 add_header X-Cache-Status ... always。「后端没有日志」不是没问题,而是请求没到后端。
  7. 两个高频排查坑:
    • nginx 官方镜像里 /var/log/nginx/access.log 是 指向 /dev/stdout 的软链接, docker compose exec nginx cat access.log 会把命令挂死(超时),请用 docker compose logs nginx;
    • 两轮实验之间必须清缓存,否则会被上一轮的缓存「喂」到而误判。
  8. 定位利器:缓存文件头部明文可读,直接 dump 就能看到 KEY: httpGEThost/uri, 一眼确认 cache key 是否缺维度。
  9. 可复用的自检清单:
    • 配置里 grep 一下 proxy_ignore_headers,有就重新评估;
    • 检查 proxy_cache_key 是否包含身份维度;
    • 用「两个无 Cookie 客户端」做冒烟测试,确认拿到不同会话;
    • 校验私有接口的 X-Cache-Status 永远不是 HIT。

相关文件

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

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-13-1600-nginx-proxy-cache-set-cookie-cache-control

分享这篇文章