本文记录一次生产站点排障经历:同一个响应里出现了两条 Strict-Transport-Security 头,顺着这条线索又牵出了 HTTP 不跳转、www 无法访问、控制面板配置陷阱等一系列问题。全程使用 curl 抓包定位,不依赖图形界面,思路同样适用于其他反代 + 后端的架构。

一、现象

用浏览器访问站点时,开发者工具里出现了两条 HSTS 头:

Strict-Transport-Security: max-age=31536000
Strict-Transport-Security: max-age=31536000; includeSubDomains

其中一条不带 includeSubDomains,另一条带。浏览器虽然会合并这类头,但重复头既不规范,也容易被安全扫描工具标记为异常。

二、复现与定位

先用 curl 抓完整响应头确认是否稳定复现:

curl -s -D - -o /dev/null https://yourdomain.com/

输出(节选):

HTTP/1.1 200 OK
Server: openresty
...
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000
set-cookie: XSRF-TOKEN=...; Path=/; HTTPOnly
Strict-Transport-Security: max-age=31536000; includeSubDomains

关键观察点:

  1. 两条 HSTS 头在响应中的位置不同
  2. 第一条不带 includeSubDomains,和 X-Content-Type-OptionsX-Frame-Options 等安全头紧挨在一起,成批出现。
  3. 第二条带 includeSubDomains,出现在 set-cookie 之后,位于响应最末尾

这说明两条头来自两个不同的层

三、根因分析:两个层级各加了一份

客户端
  │
  ▼
OpenResty / Nginx(反代)  ← add_header Strict-Transport-Security "...includeSubDomains"
  │                            (追加到响应末尾)
  ▼
后端应用(Java / Spring Security)  ← 默认安全头 Strict-Transport-Security "max-age=31536000"
                                        (和 X-Frame-Options 等一起输出)
  1. 后端应用(本站是 Halo,基于 Java 技术栈)默认输出了一批安全头,其中包含 Strict-Transport-Security: max-age=31536000(不带 includeSubDomains)。这就是第一批安全头里那一条。
  2. 反向代理 Nginx 配置里写有 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";,Nginx 会把这条头追加到代理响应的末尾。
  3. 由于 Nginx 默认透传上游所有响应头,两条 HSTS 都被原样发给了客户端,于是出现重复。

顺带发现一个有趣的细节:用 curl -I(HEAD 请求)时只看到一条 HSTS。原因是 HEAD 请求触发了后端的 404 错误路径(application/problem+json),处理链路和正常 200 不同,并非稳定行为,不能据此判断“没重复”。

四、解决方案

原则:HSTS 只保留一份,推荐统一放在反向代理这一层

在反代的 location 块里屏蔽上游的那份,只保留 Nginx 自己加的一份:

location / {
    proxy_pass http://127.0.0.1:8090;
    proxy_hide_header Strict-Transport-Security;   # 关键:去掉上游那一份
}

server 层的 add_header 保持不变:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";

改完重载并验证:

nginx -t && nginx -s reload
curl -s -D - -o /dev/null https://yourdomain.com/

只看到一条 Strict-Transport-Security: max-age=31536000; includeSubDomains,问题解决。

五、顺藤摸瓜发现的其他问题

排查过程中又陆续发现三个问题,一并记录。

1. HTTP 请求 403,不跳转 HTTPS

curl -I http://yourdomain.com/
HTTP/1.1 403 Forbidden

站点应该强制 HTTPS,但纯 HTTP 访问被直接拒绝。排查发现:

  • 原配置里有一句 if ($scheme = http) { return 301 https://$host$request_uri; },但它写在只监听 443 的 server 块里。443 上收到的永远是 https 请求,$scheme 恒为 https,这段代码永远不会执行——是死代码。
  • 80 端口实际由控制面板(1Panel)的默认 server 接管,直接返回 403。

解决办法:让 server 块同时监听 80 和 443,if ($scheme = http) 才会在 80 端口的请求上触发:

listen 80 ;
listen [::]:80 ;
listen 443 ssl ;
listen [::]:443 ssl ;
server_name yourdomain.com www.yourdomain.com;

2. www 域名无法访问

访问 www.yourdomain.com 直接超时。排查发现该子域名根本没有 DNS 解析记录

yourdomain.com     → 服务器IP
www.yourdomain.com → 无 A 记录

而 HSTS 又声明了 includeSubDomains,浏览器会把 www 也强制到 HTTPS,结果 www 根本没解析,体验更差。解决方法是去 DNS 控制台给 www 补一条 A 记录,并确认证书包含 www(SAN 或通配符)。

3. 踩到 1Panel 的一个坑:强制 HTTPS 跳转没生效

在 1Panel 里勾了“强制 HTTPS 跳转”,但 http 访问依旧不跳。查看生成的配置后发现,1Panel 的“强制跳转”只是在 server 块里加了一句 if ($scheme = http) return 301而这句话只有在该 server 块同时监听 80 端口时才有效

1Panel 的实际规则是:http 和 https、带 www 和不带 www,四种组合都要作为独立的站点配置加上,强制跳转才会对每一组都生效。只配 https 或只配不带 www 的条目,跳转就不完整。

六、全站安全响应头基线

顺手把各站点的响应头都过了一遍,整理成一张体检表:

站点 状态 主要问题
主站(Halo) 大部分合规 Cookie 缺少 SecureSameSite;缺 CSP;Server 头泄露软件栈
网盘列表站(OpenList) 问题最大 只有 HSTS,缺 X-Frame-OptionsX-Content-Type-OptionsReferrer-Policy、CSP
密码管理器(Vaultwarden) 最规范 CSP、permissions-policy、frame-options 等默认齐全,无需改动

值得推荐的通用安全头基线:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;

几个容易忽视的点:

  1. Cookie 安全属性:生产环境 Cookie 至少要 Secure; SameSite=Lax,否则明文链路下可能被窃取。
  2. 关闭软件栈泄露server_tokens off; 隐藏 Nginx 版本/软件名。
  3. 管理类端口别裸奔公网:文件列表后端 API、密码管理后台这类端口,即使有登录鉴权,也应尽量用防火墙限制到管理 IP,避免攻击面直接暴露。
  4. HSTS preload 的前提:想申请浏览器 HSTS 预加载列表,要求 http:// 必须返回 301 到 https;关闭 80 端口会让 preload 无法通过。

七、小结

  1. 重复的响应头,多半是“上游应用 + 反代”两层各自添加导致,proxy_hide_header 是标准解法。
  2. 排查响应头问题,curl -s -D - 比浏览器开发者工具更直观,还能避免浏览器缓存干扰。
  3. 死代码是最隐蔽的坑:写在只监听 443 块里的 if ($scheme = http),永远不会执行。
  4. 用 1Panel 这类控制面板,要理解它生成配置的逻辑(http/https、www/非 www 分开配置),否则“看着配了,实际没生效”。
  5. 安全头检查是运维体检的常规项,建议定期扫一遍,重点盯 Cookie 属性、CSP、管理端口暴露。