适用场景
HSTS 用于强制浏览器只通过 HTTPS 访问站点。当站点已经部署 HTTPS 证书,但用户从 http:// 输入域名访问时,仍会先发出一次明文请求,再被服务器用 301 或 302 跳转到 HTTPS。这段”明文窗口”是 SSL 剥离攻击(SSL Stripping)的入口:中间人可以在跳转响应到达浏览器之前把它替换成自己的 HTTP 页面,用户全程看到的是”正常网站”,Cookie 与登录密码却已明文经过攻击者节点。
HSTS(HTTP Strict Transport Security)用于消除这一窗口。当浏览器第一次收到 HTTPS 响应中的 Strict-Transport-Security 头后,会在本地缓存一条”该域名只准走 HTTPS”的策略,此后不管是用户手输 http://、点击站内明文链接,还是攻击者伪造跳转,浏览器都会在发出请求之前就把地址重写为 HTTPS,并且对证书错误拒绝降级放行。
以下情况适合启用 HSTS:已具备有效证书且全站资源(含图片、JS、CSS、API 子域)均已支持 HTTPS;计划长期使用 HTTPS,不打算回退;存在多级子域且已全部完成证书覆盖。反之,如果站内仍有大量硬编码 http:// 的引用,或某些子域尚未配置证书,贸然开启 includeSubDomains 会造成子域整段不可访问。
前置条件
- 已为站点签发有效证书(含中间证书链),且系统时间准确。
- Nginx 1.9.0 以上或 Apache 2.4 以上,能够直接添加响应头。
- 已确认全站无混合内容(浏览器控制台无 Mixed Content 告警)。
- 子域清单已知,且每个子域都已完成 HTTPS 改造。
- 具备回滚方案:明确 HSTS 生效后无法通过删除响应头立即撤销。
原理说明
HSTS 的核心是一条响应头:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
三个参数的作用完全不同,必须分开理解:
- max-age:浏览器记住这条策略的秒数。31536000 等于一年,是正式上线的推荐值。该值不会因为服务器后来不再发送响应头而失效,必须等到期,因此上线必须分阶段。
- includeSubDomains:策略同时覆盖所有子域,包括未在证书中列出的子域。开启后,任何 http:// 子域都会被浏览器强制重写为 https://,若子域没有证书会直接报错。
- preload:表示站点同意被写入浏览器的内置预加载列表(HSTS Preload List)。列表随浏览器版本分发,意味着用户即使从未访问过本站,第一次输入 http:// 也不会发出明文请求,彻底封死首次访问窗口。
关键差异在于 301 跳转与 HSTS 的执行位置。301 是服务器响应,明文请求已经到达服务器,攻击者可在链路上截获并改写;HSTS 是浏览器本地策略,请求在离开浏览器之前就已被改写,链路上根本不存在可劫持的明文 HTTP 请求。两者不是替代关系:正确的做法是保留 301 跳转(兼容不支持 HSTS 的旧客户端),同时叠加 HSTS。
还有两个容易忽略的细节。第一,HSTS 响应头只在 HTTPS 响应中生效,服务器在 http:// 的 301 响应里加这个头是无效的,浏览器会忽略。第二,首次访问的空窗期只能靠 preload 列表消除,单纯加大 max-age 无法解决。
操作步骤
第一步:确认全站 HTTPS 与证书链完整
curl -sSI https://www.example.com | head -n 5
curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://www.example.com
openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject
ssl_verify_result 必须为 0,表示证书链校验通过。若返回非 0,先修证书再谈 HSTS,否则开启 includeSubDomains 后浏览器会直接阻断访问且用户无法手动绕过。
第二步:Nginx 添加 HSTS 响应头
在站点的 443 server 块中加入以下配置。注意必须放在 443 监听块内,放到 80 块内无效。
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/nginx/ssl/example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# 第一步:先用 300 秒小步试探
add_header Strict-Transport-Security "max-age=300" always;
location / {
root /var/www/html;
index index.html;
}
}
参数说明:always 关键字保证该头在 4xx、5xx 响应中同样输出,避免错误页泄露明文通道;max-age=300 是灰度值,出错后 5 分钟即自动失效,可安全回滚。
若站点由多级 Nginx 代理(前置 CDN 或 SLB 回源),必须在对外暴露的那一层添加该头。回源层的 add_header 只会被节点当作普通响应头透传,能否生效取决于节点是否保留。
第三步:Apache 添加 HSTS 响应头
<VirtualHost *:443>
ServerName www.example.com
SSLEngine on
SSLCertificateFile /etc/httpd/ssl/example.com.crt
SSLCertificateKeyFile /etc/httpd/ssl/example.com.key
Header always set Strict-Transport-Security "max-age=300"
</VirtualHost>
需要先启用 headers 模块:a2enmod headers(Debian/Ubuntu)或确认 httpd.conf 中已加载 mod_headers(RHEL/CentOS)。
第四步:分阶段放大 max-age 并叠加子域
按下面的节奏推进,每一阶段观察期不少于 24 小时,重点检查移动端 App、微信内置浏览器、旧版 IE 与第三方回调地址:
- 第 1 天:
max-age=300 - 第 2 天:
max-age=86400(一天) - 第 4 天:
max-age=2592000(30 天) - 第 7 天:
max-age=63072000(两年),此时可考虑加 includeSubDomains - 第 14 天:
max-age=31536000; includeSubDomains; preload
子域覆盖确认命令,逐个检查是否全部 200 且证书有效:
for h in api static cdn mail; do
echo -n "$h.example.com "
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' "https://$h.example.com/" || echo FAIL
done
第五步:配置 301 跳转作为兜底
HSTS 只对”记住过策略”的浏览器有效,爬虫与旧客户端的首个请求仍需服务器强制跳转:
server {
listen 80;
server_name example.com www.example.com;
# 301 用完整 URL,避免相对跳转被劫持
return 301 https://www.example.com$request_uri;
}
注意这里 不要添加 Strict-Transport-Security 头,如前所述它在明文响应中无效。
第六步:提交 HSTS 预加载列表
提交前必须同时满足三个硬性条件,否则会被拒绝:证书覆盖所有子域;http:// 与 https:// 均返回同一内容(不重定向到别的主机名);max-age 至少 31536000 且带 includeSubDomains 与 preload。满足后访问 hstspreload.org 提交域名,等待进入 Chrome 内置列表。进入后移除是一个以月计的流程,因此这一步务必最后做。
配置验证
# 1. 确认响应头存在且参数正确
curl -sSI https://www.example.com | grep -i strict-transport-security
# 2. 确认明文入口仍然跳转
curl -sSI http://www.example.com | head -n 3
# 3. 确认错误响应同样带 HSTS 头(always 生效)
curl -sSI https://www.example.com/__not_exist__ | grep -i strict-transport-security
# 4. 本地做一次 HSTS 编码后的访问验证(curl 不做 HSTS 缓存,仅验证头)
curl -sS -D - -o /dev/null https://www.example.com
浏览器侧验证:Chrome 地址栏输入 chrome://net-internals/#hsts,在 Domain Security Policy 中查询域名,可见 dynamic(自己站点下发)或 static(预加载列表中)状态。也可以在首次访问后断开网络再手输 http:// 地址,若浏览器直接报”无法访问”而非跳转,说明策略已本地生效。响应头也可在 Grafana/Prometheus 或安全扫描器的 HSTS 检查项中持续巡检。
常见问题
Q1:加了 HSTS 头但浏览器似乎不生效,可能是什么原因?
按以下顺序排查:一是响应头加在了 80 端口的 server 块中,明文响应里的 HSTS 会被浏览器忽略,必须放在 443 块;二是用了 add_header 但响应状态码为 4xx/5xx,未加 always 时 Nginx 不会输出该头;三是站点前面还有一层 CDN/反向代理重写了响应头,需要在外层节点配置;四是站点被其他插件或中间件清除了自定义头;五是访问的是子域而策略只覆盖了主域。用 curl -sSI 确认浏览器实际收到的头,再逐层比对。
Q2:开启 includeSubDomains 后某个子域打不开了,怎么恢复?
已经被浏览器记住策略的用户会持续被强制读取 https://,此时最快的手段是给该子域补发证书或把它解析到一个能正确处理 TLS 的位置,而不是删除响应头——删除对已缓存策略无效。如果子域确实无法提供 HTTPS,可将该子域解析到一台配置了自签证书并返回固定提示页的服务器,或者对仅内部使用的子域改用独立域名,使其不受主域策略约束。这正是必须分阶段上线、先小 max-age 的原因。
Q3:HSTS 会不会影响微信内置浏览器或 App 的接口调用?
HSTS 由浏览器内核实现,微信内置浏览器(X5/Chromium 内核)会遵循该策略。风险点在于 App 内若硬编码了 http:// 接口或自签证书,开启 includeSubDomains 后会被直接阻断。上线前必须让客户端团队确认接口地址全部为 https://,并确保服务端证书链完整(部分 Android 版本对中间证书缺失更敏感)。建议在上线阶段保留一个不使用 HSTS 的独立子域给内部系统,避免一次改动影响不可控的客户端。
总结
HSTS 是成本极低、收益明确的传输层加固手段:一条响应头即可消除 301 跳转阶段的 SSL 剥离窗口。落地要点可归纳为四条:头必须加在 443 块并用 always 输出;max-age 从 300 秒起分阶段放大,避免不可逆;includeSubDomains 必须在确认全部子域具备证书后再开启;preload 是最后一步,提交前务必确认不再需要回退。配合 301 兜底跳转、证书链完整校验与持续巡检,站点的 HTTPS 才算真正闭环。