本文主要记录在 Nginx 上开启 TLSv1.3 0-RTT(ssl_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 dataproxy_set_header Early-Data $ssl_early_data;:把是否 early data 传给上游,便于后端拒绝重放(见 nginx ssl_early_data)
升级后本地冒烟“首页能开、能登录”,但随后出现线上 403 反馈。
环境背景
| 项目 | 说明 |
|---|---|
| 入口 | 公共 Nginx(TLS 终结) |
| 下游 | 多层 Nginx / 缓存层(按 Host / server_name 转发) |
| Nginx | 示例 1.19.1(不同小版本均可复现,见下文) |
| OpenSSL | 1.1.1 系列(示例 1.1.1g) |
| 变更范围 | 主要为配置项,非二进制回退 |
真实业务域名、内网节点已脱敏。架构共性是:从入口到应用之间存在两层及以上 Nginx 反代,转发依赖 Host 等头。
排查过程
隔离节点复现
为避免影响全量流量,可在负载均衡侧将某台 Nginx 权重降为 0,待连接排空后:
- 暂停配置管理系统对该节点的自动下发(若有),避免复现中途被回滚
- 仅在该节点打开
ssl_early_data与proxy_set_header Early-Data ... - 客户端改 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;
历史抓包示意:

原因分析
根因可以概括为:
- 为传
Early-Data,在server块 增加了一条proxy_set_header - 一旦
server层出现任意proxy_set_header,不再继承http层已有的全部proxy_set_header(含业务自定义头,以及你以为“默认还在”的Host行为,取决于你原先写在哪一层) - 若
location层也没有再完整补齐proxy_set_header,上游收到的请求会缺少Host等关键头 - 下游按
Host/server_name选虚拟主机失败时,落到default_server;若该default_server对根路径直接return 403,用户侧就表现为首页 403
也就是说:0-RTT 本身协商未必错,错在“为 0-RTT 加一个头”触发了 Nginx 的 header 继承切断,在多层代理里把路由关键头弄丢了。
解决方法
针对多层反代 + 0-RTT,常见有三条路。
方案一:统一在 http 层写 proxy_set_header
与其他自定义转发头一样,把 Early-Data 和 Host、X-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。若必须开启:
- 在同一配置层级集中声明所有发往上游的头(含
Host、Early-Data) - 任意子层一旦出现
proxy_set_header,把该层需要的头全部显式写全 - 用抓包或
mirror/log验证上游实际收到的请求头 - 后端对
Early-Data: 1的请求按幂等/拒重放策略处理
验证结果
- 去掉
ssl_early_data与单独的proxy_set_header Early-Data后,403 消失(回滚验证) - 在隔离节点复现:开启不当的
server级proxy_set_header后,下游再次因缺Host落入default_server403 - 修复方向验证:在正确层级补齐
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”就当全链路通过。