本文主要记录在 Nginx 上开启 TLSv1.3 0-RTTssl_early_data)后,首页被下游返回 403 的排查过程,以及 proxy_set_header 继承规则与多层代理架构下的取舍。

相关背景可参考站内 nginx 启用 HTTP/2 和 TLSv1.3,以及同系列关于 HTTP/2 与 ssl_ciphers 的记录。本文聚焦 0-RTT 相关配置如何踩中 proxy_set_header 作用域规则

问题现象

在公共 Nginx(二进制与 OpenSSL 已支持 TLS 1.3,并稳定运行一段时间)上追加 0-RTT 相关配置后,业务首页出现 403。去掉 0-RTT 两项配置并回滚后恢复。

当时追加的核心配置示意:

ssl_ciphers                 ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:AES128-GCM-SHA256:AES128-SHA256:AES128-SHA;
ssl_protocols               TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ecdh_curve              X25519:P-256:P-384:P-521;
ssl_early_data              on;
proxy_set_header            Early-Data $ssl_early_data;

说明:

  • ssl_early_data on:开启 TLSv1.3 0-RTT early data
  • proxy_set_header Early-Data $ssl_early_data;:把是否 early data 传给上游,便于后端拒绝重放(见 nginx ssl_early_data

升级后本地冒烟“首页能开、能登录”,但随后出现线上 403 反馈。

环境背景

项目说明
入口公共 Nginx(TLS 终结)
下游多层 Nginx / 缓存层(按 Host / server_name 转发)
Nginx示例 1.19.1(不同小版本均可复现,见下文)
OpenSSL1.1.1 系列(示例 1.1.1g)
变更范围主要为配置项,非二进制回退

真实业务域名、内网节点已脱敏。架构共性是:从入口到应用之间存在两层及以上 Nginx 反代,转发依赖 Host 等头。

排查过程

隔离节点复现

为避免影响全量流量,可在负载均衡侧将某台 Nginx 权重降为 0,待连接排空后:

  1. 暂停配置管理系统对该节点的自动下发(若有),避免复现中途被回滚
  2. 仅在该节点打开 ssl_early_dataproxy_set_header Early-Data ...
  3. 客户端改 hosts 指向该节点,复现 403

历史复现环境示意:

日志:403 来自第二层,且 Host 丢失

查看链路日志可以发现:

  • 403 由第二层 Nginx 返回给第一层公共 Nginx
  • 发往下游的请求里,Host 头缺失或不正确

在不同 Nginx 小版本上均可复现,因此更像是配置语义问题,而不是单一版本 bug。

抓包与 proxy_set_header 语义

抓包显示:开启 0-RTT 相关 proxy_set_header 后,转发请求中部分本应存在的头消失。对照官方文档 proxy_set_header

  • 可在 http / server / location 配置
  • 仅当当前层级没有定义任何 proxy_set_header 时,才会从上一层级继承
  • 默认会重写的字段包括:
proxy_set_header Host       $proxy_host;
proxy_set_header Connection close;

历史抓包示意:

原因分析

根因可以概括为:

  1. 为传 Early-Data,在 server 增加了一条 proxy_set_header
  2. 一旦 server 层出现任意 proxy_set_header不再继承 http 层已有的全部 proxy_set_header(含业务自定义头,以及你以为“默认还在”的 Host 行为,取决于你原先写在哪一层)
  3. location 层也没有再完整补齐 proxy_set_header,上游收到的请求会缺少 Host 等关键头
  4. 下游按 Host / server_name 选虚拟主机失败时,落到 default_server;若该 default_server 对根路径直接 return 403,用户侧就表现为首页 403

也就是说:0-RTT 本身协商未必错,错在“为 0-RTT 加一个头”触发了 Nginx 的 header 继承切断,在多层代理里把路由关键头弄丢了。

解决方法

针对多层反代 + 0-RTT,常见有三条路。

方案一:统一在 http 层写 proxy_set_header

与其他自定义转发头一样,把 Early-DataHostX-Forwarded-*写在同一层级(通常是 http;仅在确有特殊需求的 location 完整重写该层需要的全部 proxy_set_header

  • 优点:与现有“统一在 http 设头”的习惯一致,侵入面可控
  • 缺点:任何在 server/location 再写一条 proxy_set_header 都会切断继承,容易漏补 Host / Early-Data;漏掉 Early-Data 还会削弱防重放能力

方案二:改用 add_header(一般不推荐用于转上游)

add_header 作用于响应头,语义与 proxy_set_header(改写发往上游的请求头)不同。不要用“加响应头”的思路替代 0-RTT 的 Early-Data 传递。官方 0-RTT 防重放指引也是围绕请求侧 Early-Data 头展开的。

若有人考虑用其它指令“追加”请求头,必须核对模块语义,避免误用。

方案三:暂不开启 0-RTT(当时倾向)

0-RTT 用一定的重放风险换首包时延。在多层 Nginx 嵌套、header 继承规则容易踩坑、且业务侧未必完整消费 Early-Data 的情况下,收益可能小于风险。

即便关闭 0-RTT,TLSv1.3 全握手相对 TLS 1.2 仍有延迟与安全收益,不必把 0-RTT 当作升级必选项。

结论(工程取舍)

在未理顺“每一层 proxy_set_header 是否完整、后端是否严格处理 Early-Data”之前,更稳妥的是不开启 ssl_early_data。若必须开启:

  1. 同一配置层级集中声明所有发往上游的头(含 HostEarly-Data
  2. 任意子层一旦出现 proxy_set_header,把该层需要的头全部显式写全
  3. 用抓包或 mirror/log 验证上游实际收到的请求头
  4. 后端对 Early-Data: 1 的请求按幂等/拒重放策略处理

验证结果

  • 去掉 ssl_early_data 与单独的 proxy_set_header Early-Data 后,403 消失(回滚验证)
  • 在隔离节点复现:开启不当的 serverproxy_set_header 后,下游再次因缺 Host 落入 default_server 403
  • 修复方向验证:在正确层级补齐 proxy_set_header Host ... 与业务所需头后,转发恢复(具体配置以现网为准)

判断标准:浏览器首页 200;入口与下一跳日志中 Host 与路由一致;若开启 0-RTT,上游能看到符合预期的 Early-Data 值。

注意事项

  • proxy_set_header 不是“追加一条”,而是“本层一旦出现,就不再继承上层”。这是多层反代里最高频的坑之一,与是否 0-RTT 无关,0-RTT 只是触发器。
  • 开启 ssl_early_data 前,确认证书、会话票据/PSK、客户端支持,以及所有上游对重放的处理;只在边缘加头、后端忽略,防重放等于没做。
  • 配置管理(Puppet/Ansible 等)会覆盖手工修改;复现与灰度时要隔离自动化,避免“修了又被推回去”。
  • 生产变更建议:先隔离节点 → 验证头与状态码 → 再放量;不要只做“本机 curl 首页 200”就当全链路通过。

参考资料