西西软件园多重安全检测下载网站、值得信赖的软件下载站!
软件
软件
文章
搜索

首页 → 业内动态 → 业内资讯 → HSTS 配了 3 个月还在被降级:首次访问的安全缺口在这

HSTS 配了 3 个月还在被降级:首次访问的安全缺口在这

相关软件相关文章发表评论 来源:本站整理时间:2026/11/4 16:27:42字体大小:A-A+

作者:xiix点击:0次评论:0次标签: HSTS 安全响应头 HTTPS 强制HTTPS 浏览器降级

  • 类型:站长工具大小:8.0M语言:中文 评分:10.0
  • 标签:
立即下载

事情是安全同事先发现的。我们的后台管理系统早就全站 HTTPS 了,Nginx 里也老老实实加上了 HSTS 响应头:

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

配置改完三个月,期间还专门找做渗透测试的同事验证过,扫描报告里 HSTS 这一项确实是绿的。但最近一次内部演练,有人用一台刚装好的笔记本访问后台,地址栏右边直接挂了个"不安全"的标记,点进去能看到「连接不是私密连接」这一行提示。截图发到群里,安静了半小时。

配置明明在,为什么还会有"不安全"?后来花了两天时间,把 HSTS 相关的每一个环节都拆开看了一遍,最后发现是四个缺口叠在一起,任何一个单独存在都不致命,凑齐了就会在用户第一次访问的那个瞬间暴露出来。

要验证 HSTS 到底有没有真正生效,最直接的土办法就是拿不同浏览器的干净会话反复访问,顺便用线上抓包把每个响应头看一遍。LinkCLI 的海外高防线路和隐私保护做得比较到位,适合对外暴露的业务站点做多地访问验证;vollcloud 的香港 CMIN2 大带宽节点可以做跨地域回源测试,能更早暴露证书链或者回源策略上的问题。下面这几个排查动作,都是在这套环境上一步步验证出来的。

缺口一:首访的那一次,永远不受 HSTS 保护

这是 HSTS 最容易被误解的地方,也是这次问题的根源。

HSTS 的工作原理依赖浏览器本地缓存:浏览器第一次访问你的站点时,服务端返回的 HSTS 头会被浏览器记下来(实际上是缓存成一条域名记录),之后再来访问,浏览器就直接用 HTTPS 发起连接,连第一次那个 HTTP 请求都不会有。

问题就在"第一次"这三个字上。用户的浏览器是全新的、缓存是空的、或者是换了设备、清了缓存、换了网络环境之后,这份 HSTS 记录不存在,浏览器就会按老规矩先发一个 HTTP 请求过去。这一次请求在传输层是明文的,中间人能看到你访问的域名,也能看到后续 301 跳转的地址(虽然跳转之后的正文是加密的)。

所以结论是:HSTS 保护不了首访,它保护的是首访之后的每一次访问。要补上首访这一环,靠的是下面两项配置,而不是指望 HSTS。

# 1. 预加载:把域名直接提交到浏览器内置的 HSTS 预加载列表
# 2. 预连接:让浏览器在第一次访问前就把 TCP/TLS 连接准备好

预加载列表是浏览器厂商维护的,提交之后 Chrome、Firefox、Safari 等主流浏览器在首次访问前就已经知道这个域名必须走 HTTPS,连第一次的明文请求都不会发出去。提交需要满足一套比较严格的条件,比如有效期至少一年、必须带 includeSubDomains、必须全站支持 HTTPS、不能有混合内容、证书必须走受信任的 CA 且有效期在 13 个月以上。很多人以为配了 HSTS 就万事大吉,实际上没进预加载列表,HSTS 只保护二访及以后。

我们这次是两条路一起走:一条是老老实实申请加入预加载列表,另一条是加了 HSTS 预连接提示头。预连接不能替代预加载,但能让首访的握手过程更顺,配合预加载之后首访的明文窗口基本被消掉了。

缺口二:证书链不完整,浏览器直接降级

第二个坑更隐蔽。我们用的证书是云服务上直接签发的,浏览器访问没问题,但用 curl 加一个不信任中间 CA 的参数去连,就报了一串证书错误。

原因在于服务器上只部署了叶子证书,中间 CA 的证书没有一起下发。这在现代浏览器上可能被自动补全(浏览器有缓存的中间证书),但在某些 Android 设备、部分国产浏览器的定制内核、以及大量只带根证书的老旧系统上,握手会直接失败。

失败之后会发生什么?浏览器直接放弃 HTTPS,不再尝试降级到 HTTP,但页面上给出的提示是"连接不安全"或者干脆一个错误页。用户和运维看到的就是"配了 HTTPS 还是会提示不安全"。

验证方法很直接,在服务器上把完整的证书链拼起来再对比:

# 完整链应该是:叶子证书 + 中间 CA
cat /etc/ssl/certs/fullchain.pem | openssl x509 -noout -text | head -20

# 看服务器实际发出去的是几张证书
openssl s_client -connect yoursite.com:443 -servername yoursite.com -showcerts 2>/dev/null   | grep -c "BEGIN CERTIFICATE"

我们把中间 CA 补齐之后,握手失败率从 0.7% 降到了 0。另外顺手把证书链的完整度做成了上线前的自动化检查项,避免以后换证书再踩一遍。

缺口三:重定向的顺序反了,HSTS 头送晚了

第三个问题是响应头顺序。这类配置经常写成这样:

server {
    listen 80;
    location / {
        return 301 https://$host$request_uri;
    }
}
server {
    listen 443 ssl;
    # HSTS 头写在这里
}

看起来没问题:80 端口负责跳转,443 端口返回 HSTS。但用户从 http 进来时,走的是 80 端口那个 server 块,那个块里根本没有 HSTS 头。所以这一次访问虽然被 301 引导到了 HTTPS,但这次响应本身是没有 HSTS 标记的。

更糟的情况是有人图省事,把 80 和 443 写在同一个 server 块里,用 if ($scheme = http) { return 301 ... } 做跳转。结果是 if 块先执行,return 直接把响应发出去,同一个 location 里的 add_header 根本没机会执行,HSTS 头同样丢了。

正确的做法是让 80 端口也带上 HSTS 头,同时保证 HSTS 出现在跳转响应里:

server {
    listen 80;
    server_name yoursite.com;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    return 301 https://$host$request_uri;
}

还有一个更标准的做法是让 80 端口也直接返回 200 加 HSTS,而不是 301。这样浏览器第一次访问就能把 HSTS 记下来,第二次起就完全走 HTTPS,中间不再有明文请求。配合预加载,首访问题才算真正闭环。

缺口四:max-age 太短,缓存悄悄过期

最后一个缺口跟"三个月"这个时间点有关。我们的配置写的是 max-age=15552000,也就是 180 天。这个值本身不算离谱,但有个细节:HSTS 缓存的有效期是按浏览器的本地时间算的,而且不会因为你重新访问而刷新为新值,它取的是所有响应里最小的那个 max-age。

问题出在灰度发布期间。那段时间为了兼容一个还没升级的老客户端,我们临时把某个入口的 max-age 改成了 300 秒,那个入口被 CDN 缓存了一阵子。结果是所有从那个入口进来的用户,浏览器里记下的 HSTS 有效期都只有 5 分钟。用户下次访问已经过期,浏览器又按老习惯先走明文,一次一次重复。

所以监控上要加一条:定期用真实浏览器验证 HSTS 是否还生效,而不是看配置文件里写了什么。

curl -sI https://yoursite.com/ | grep -i strict-transport-security
# 期望输出包含 max-age,且数值与预期一致

我们后来把 HSTS 的检查做成了一个每小时的自动化巡检:从多个节点用干净会话访问,解析响应头和证书链,异常直接告警。这一步补上之后,首访降级的问题再没出现过。

最终的配置长什么样

把四个缺口补齐之后,配置收敛成这样:

map $sent_http_content_type $hsts_header {
    default   "max-age=63072000; includeSubDomains; preload";
}

server {
    listen 80;
    server_name yoursite.com;
    add_header Strict-Transport-Security $hsts_header always;
    add_header Strict-Transport-Security $hsts_header always;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yoursite.com;
    ssl_certificate     /etc/ssl/fullchain.pem;
    ssl_certificate_key /etc/ssl/private.key;
    add_header Strict-Transport-Security $hsts_header always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
}

有两处值得单独说。一是有效期从 180 天提到了 2 年,加上 preload 属性,这样才能符合预加载列表对最短有效期的要求。二是证书换成了 fullchain,也就是把中间 CA 一起打进文件,服务器握手时一次发全,不用指望浏览器缓存去补。

顺带说一句,加载 HSTS 响应头之后要留意一件事:所有子域名都会被强制走 HTTPS。所以 includeSubDomains 打开之前,最好先把所有子域名都盘一遍,确认它们真的都支持 HTTPS、证书都有效,否则会出现某个已经没人维护的子域名(比如老测试站)突然打不开的情况。我们这次就发现了一个挂在旧测试域上的子站,证书早就过期了,只是从来没人访问所以没暴露。

留下的几条笔记

第一,HSTS 管不了首访,首访要靠预加载。把 HSTS 当成"配了就万事大吉"是常见误区,第一次访问的安全窗口只能用预加载列表和预连接来补。

第二,证书链不完整等于没配 HTTPS。握手失败在浏览器上表现为"不安全",而不是"证书过期",很容易误判成配置问题。部署时一定用 fullchain,上线前用 openssl s_client 抽查一次。

第三,HSTS 头必须出现在每一个响应里。包括 80 端口的跳转响应,包括错误页和自定义返回。用 always 参数,别让默认行为把它漏掉。

第四,max-age 会被最小的那个响应拉低。灰度期间临时改小有效期,代价是所有用户缓存被刷新。别临时改,要改就一起改,然后等浏览器自然过期。

回头看这次排查,最花时间的其实不是配置本身,而是"以为配了"这个前提。HSTS 这类只在响应头里存在的策略,最怕的就是用配置文件代替真实验证配置写对了不代表浏览器真的记住了,反过来也一样。补一套基于真实浏览器会话的自动化巡检,比反复读配置文件管用得多。

浏览器 HTTPS 锁形标志安全示意图

HSTS 响应头配置与浏览器盾牌图标

浏览器不安全连接警告界面

服务器安全响应头配置排查

    相关评论

    阅读本文后您有什么感想? 已有人给出评价!

    • 8 喜欢喜欢
    • 3 顶顶
    • 1 难过难过
    • 5 囧囧
    • 3 围观围观
    • 2 无聊无聊

    发表评论

    昵称:
    表情: 高兴 可 汗 我不要 害羞 好 下下下 送花 屎 亲亲