一、HSTS 到底解决了什么问题
先说清楚它防的是什么。用户第一次访问一个只有 http 的地址(比如直接在地址栏敲域名,或者点了别人给的一个 http 链接),请求是明文发出去的。中间任何一跳都能看到你要访问哪个站,也能在响应回来之前把内容替换掉——把登录页换成钓鱼页,把下载链接指向一个坏的安装包。这就是常说的中间人攻击。
服务器配置 301 跳转到 https 能挡住大部分,但有三个漏洞:第一跳仍然是明文,人家在你收到 301 之前就把响应改了;用户点「继续访问」就绕过了;以及 cookie 如果在 http 上被发过一次,已经泄露了。
HSTS 的作用是让浏览器记住一件事:这个域名在接下来的一段时间内,只准用 https 访问,任何 http 请求在浏览器内部就直接被改写成 https,不发明文请求。 它由服务器通过一个响应头下发:
Strict-Transport-Security: max-age=31536000; includeSubDomains看起来很简单的两个参数,但真正出事故的全在 includeSubDomains 和 preload 这两个可选项上,以及「一旦下发就收不回来」这个特性上。
二、max-age 的单位是秒,而且它是在用户机器上
max-age=31536000 是一年。这个值不是服务器上的配置,是浏览器存进本地数据库的一个条目,过期时间由它自己算。所以你在服务器上把 max-age 改成 0 或者删掉这个头,已经访问过的用户的浏览器里那条记录仍然在生效,直到它过期。
这就是 HSTS 最需要警惕的地方:它是一份下发到成千上万个客户端、你无法召回的配置。
实际影响是什么?假设你给 example.com 下发了 includeSubDomains,而你有一台 nas.example.com 只提供 http 管理界面,自签证书。上线 HSTS 之后,你家里的这台设备就再也打不开了——浏览器强制要求 https,自签证书被拒,报错是 NET::ERR_CERT_AUTHORITY_INVALID,而且没有「继续访问」的按钮(HSTS 页面不允许点击绕过)。
要恢复只有两条路:等 max-age 过期(一年),或者用户手动在 chrome://net-internals/#hsts 里删除这个域名。你不能要求客户这么干。
所以第一条铁律:上线前先数清楚这个域名下面有多少个子域名,以及它们是否都支持有效的 https。
三、上线前的盘点清单
这一步花半小时,能省掉一次事故。盘点方式很直接:
# 1) 从证书里看有哪些子域名
openssl x509 -in /etc/nginx/ssl/example.crt -noout -text | grep -A2 "Subject Alternative Name"
# 2) 从 DNS 里看实际存在的记录
dig +short example.com A
dig +short example.com MX
dig +short example.com TXT
# 逐个常见子域探一下
for h in www mail ftp cdn static img api admin nas dev test; do
ip=$(dig +short $h.example.com A | head -1)
[ -n "$ip" ] && echo "$h -> $ip"
done然后对每个探到的子域名做一次 https 检查:
for h in www mail api admin; do
echo "== $h"
curl -sI -m 8 "https://$h.example.com/" -o /dev/null \
-w "code=%{http_code} ssl=%{ssl_verify_result}\n"
donessl_verify_result=0 才算证书校验通过。非 0 的那些,就是这个域名下不能上 includeSubDomains 的绊脚石。常见的有几类:
- 邮件服务器:
mail.example.com只有 25/993 端口,没有 https。这本身不受影响(HSTS 只管 http/https),但如果有人在浏览器里访问过http://mail.example.com,它会被强制升级到 https 然后失败。 - 自建的管理后台或 NAS:自签证书,正是最容易被打断的场景。
- CDN 回源域名、对象存储的绑定域名:这些往往只在特定路径可用,直接访问根路径返回 404,看着像坏了。
- 已经不用但 DNS 还留着的记录:指向一台早就下线的机器,https 直接连不上。
盘点的结论只有两种:全都能用有效 https,那就带上 includeSubDomains;有一个不行,那就不带。 不带 includeSubDomains 的 HSTS 只保护主域名,效果打了折扣,但远比把用户的管理后台打死要好。
四、先短后长:max-age 应该分两步走
不要第一次就写一年。推荐的上线节奏是这样:
第一阶段,max-age=300。三百秒,五分钟。这个阶段唯一的目的就是验证没有任何东西被打断。让一批真实用户访问一天,看有没有人报告某个子域打不开、某个内部工具连不上。因为你只承诺了五分钟,出事的话等五分钟就自动失效。
第二阶段,观察三天没问题后,改成 max-age=86400(一天)。再观察一周。
第三阶段,确认稳定后,改成 31536000(一年)。
这个「先短后长」的做法是有代价的:max-age 短的阶段,保护力度也弱。但上线过程本身是风险最高的窗口,用短 max-age 换取可回退,是值得的。真正的长期防护靠第三阶段。
另外,max-age 的更新只在用户下一次访问时生效。如果某用户在你设成 300 秒的那天访问过一次,之后一个月没来,他下次来的时候会拿到新的 31536000,这没问题。但如果他恰好在你回退配置的窗口期没访问,那他浏览器里存的还是 300 秒,过期后就完全没有了。这也是为什么回退必须靠改配置加等待,不能指望「马上就好」。
五、Nginx 里怎么配
HSTS 头只能通过 https 响应下发,http 的响应里的 HSTS 头会被浏览器忽略。所以配置要放在 443 的 server 块里,而且要放在所有 location 之外,保证每个响应都带上——包括错误页。一个典型的写法:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.crt;
ssl_certificate_key /etc/nginx/ssl/example.key;
# 放在 server 级,所有响应都会带上,包括 404/500
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
root /var/www/example;
try_files $uri $uri/ /index.php?$query_string;
}
}always 这个参数必须加。不带它,add_header 只在 200、201、204、206、301、302、303、304、307、308 这些状态码上生效,遇到 404 或 500 就不加了。这不是致命的(错误页本身不承载敏感数据),但会让你的「是否已下发」的检测出现误判:你 curl 一个不存在的路径,看到没有这个头,以为配置没生效。
在 80 端口的 server 块里做全站 301 跳转:
server {
listen 80;
server_name example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example;
allow all;
}
location / {
return 301 https://example.com$request_uri;
}
}那个 ^~ 的 acme 放行是必须的,否则证书续期时 http-01 验证会被 301 挡掉,续期失败。这一条在自动续期场景里是高频事故。
六、preload 是另一回事,别急着上
preload 这个指令的意思是:把域名提交到一个由浏览器厂商维护的列表里。example.com 进了这个列表之后,浏览器是内置就知道「这个域名只能用 https」的——哪怕用户从来没访问过。这解决了 HSTS 的「第一次访问仍然是明文」的鸡生蛋问题。
但代价很大,而且几乎是不可逆的:
- 提交到
hstspreload.org之后,要等 Chrome、Firefox、Safari 各自的下个版本才生效,几周到几个月。 - 移除非常慢,官方说「需要几个月」,实际上半年以上很常见。而且各个浏览器的移除并不同步。
- 提交条件有一条硬要求:所有子域名(包括将来的)都必须支持 https。这比 includeSubDomains 更进一步,因为它意味着你这辈子在这个域名下都不能再起任何 http 服务。
我的建议很直接:除非你运营的是一个纯 www 的正经站点,并且能确定未来几年不会在这个域下开任何 http 服务,否则不要上 preload。 靠 max-age=31536000; includeSubDomains 已经能防住 99% 的攻击场景,因为攻击者要下手一般也得等人访问过一次——而绝大多数用户是回头客。
另外有一个容易忽略的细节:如果你用了 preload,用户访问 http://example.com 时浏览器根本不发请求,直接内部改成 https。所以你的 80 端口那套 301 规则对这部分用户完全不起作用——它只对还没进 preload 列表的用户有效。
七、和 CDN、反向代理一起用时的三个坑
坑一:CDN 把 HSTS 头吃掉或者重复。 部分 CDN 会重写响应头,或者在源站头的基础上再加一个。两个 HSTS 头同时在响应里,浏览器按第一个处理,未必是你要的那个。检查方法是对着 CDN 后面的域名 curl 一次,数一下这个头出现几次:
curl -sI https://example.com/ | grep -ci strict-transport-security结果是 1 才对。大于 1 说明有两处在加,去 CDN 的配置里去掉一处。
坑二:同一份配置被复制到了测试站。 这是最容易出事的一种。你有 example.com 和 staging.example.com,为了让配置一致,把 server 块直接复制过去,server_name 忘了改、或者 include 了一个共享的 snippet,于是测试站也下发了一年期的 HSTS。而测试站经常挂自签证书或者根本没有 https,结果是团队所有人的浏览器都打不开测试环境,只能去清浏览器缓存——每个人都要清。
坑三:内网域名误伤。 有些公司的内网域名和公网同名(比如 intranet.example.com 只在内网 DNS 里有解析)。如果公网站点下发了 includeSubDomains,员工在内网访问这个域名时同样会被强制 https,而内网那台机器只有 http 服务,直接打不开。
所以配置 HSTS 时,我的习惯是把这一行单独写成 include 文件,只在那几个确认无误的 server 块里引用,而不是放进全局的 http 块。一次写错,影响的是整个域名下所有站点。
八、怎么验证真的生效了
三件事要分别验证,很多人只做了第一件就以为完成了。
第一,响应头是否下发。
curl -sI https://example.com/ | grep -i strict-transport-security
# 期望:strict-transport-security: max-age=31536000; includeSubDomains顺手验一下错误页也带:curl -sI https://example.com/this-does-not-exist,应该同样有这个头(这就是加 always 的意义)。
第二,浏览器是否真的执行了。 这一步 curl 验证不了,curl 不会记 HSTS。方法是在 Chrome 里访问一次 https 站点,然后在地址栏输入 http://example.com(明文的),按 F12 打开网络面板看这次请求:如果地址被自动改写成 https,且没有任何一个 http 请求出现,说明生效了。更直接的是打开 chrome://net-internals/#hsts,在 Query 里输入域名,能看到 dynamic_sts_domain 和 sts_domain 的记录与过期时间。
第三,子域名没被打断。 这是最容易被跳过的一步。把你盘点清单里的每个子域在浏览器里打开一次,确认正常。特别是那些平时不太访问的(备份系统、监控面板、旧的后台)。
如果发现某个子域被打断,处置顺序是:先改服务器配置去掉 includeSubDomains(或把 max-age 改成 0),再让浏览器重新获取一次响应头(访问一次主站),最后在 chrome://net-internals/#hsts 里手动删除该子域的记录,才能立刻恢复。注意「手动删除」这一步只有你自己能做,普通用户做不了,所以对用户的影响就是你配置错误的时间长度。
九、什么时候该主动下线 HSTS
几乎不需要。HSTS 是一个「设了就不用管」的东西,前提是当初设的范围是对的。但有一种情况必须主动处理:域名要迁移或者停用的时候。
假设 old.example.com 要迁到 new.example.com,而 old 下发过一年期的 HSTS。用户仍然会访问 old,如果你在 old 上配了 301 跳到 new,这没问题,https 到 https 的跳转完全正常。但如果你把 old 的 https 关掉了(比如为了省钱不再续证书),那么所有访问过 old 的用户都会看到一个证书错误页,而且没有「继续访问」的按钮——他们连跳转的机会都没有。
所以域名迁移时的正确顺序是:先保留 old 的 https 和 301 至少一年(与 max-age 等长),或者提前把 old 的 max-age 降到 0 让记录自然过期,等过了这个窗口再下线。
同样的道理适用于任何「打算把某个子域停掉」的场景。停之前,先确认它有没有被 includeSubDomains 覆盖,如果有,先把它从 DNS 里摘掉并保留至少一个过期周期,别急着关机器。
十、小结
HSTS 是一份下发到所有访问者浏览器、你无法立即召回的配置。它的核心风险不在技术实现,而在范围判断:includeSubDomains 覆盖了哪些子域、preload 承诺了多长时间、max-age 设了多久。这三个决定一旦下发,回退的最小单位是「等过期」。
因此正确做法只有一条:先盘点子域名,全部支持有效 https 才带 includeSubDomains;max-age 从 300 秒起步,逐级放大到一年;preload 除非有绝对把握,否则不碰。技术上就是一行 add_header 加一个 always,剩下的全是判断和等待。