HTTPS 之后的第二道门:HSTS 与 CSP 安全响应头的落地配置与生效验证

很多站长给站点上了 HTTPS,就觉得「安全」这件事已经办完了。证书有了,地址栏有小锁,搜索引擎也给 HTTPS 加分,看起来一切正常。但只要打开浏览器开发者工具,或者跑一次安全扫描,往往会发现真正的问题:站点压根没告诉浏览器「以后只用 HTTPS」,也没有限制浏览器能加载哪些脚本。前者叫 HSTS,后者叫 CSP。这两样东西不是锦上添花,而是 HTTPS 之后最基础的两层防护,而且配置起来并不复杂——复杂的是配置完之后「到底生效了没有」。

这篇文章不讲抽象的安全理论,只讲一个自建站能落地的完整流程:HSTS 怎么设、preload 要不要申请、CSP 怎么从零一点点收紧而不是一上来就把自己站点搞白屏、report-only 模式怎么用、以及配置完成后用什么方法验证它真的生效了。全部以 Nginx 为例,其他服务器套一层响应头逻辑是相通的。

一、先搞清楚:HSTS 到底解决什么问题

启用 HTTPS 之后,一次典型的访问流程是这样的:用户在地址栏输入 example.com,浏览器先发一个明文 HTTP 请求到 80 端口,服务器返回 301 跳转到 https。这个过程里,那个明文请求是完全暴露在链路上的。在咖啡厅 WiFi、酒店网络这种环境里,攻击者可以在这个窗口做 SSL Strip:把 301 跳转拦下来,自己跟服务器建立 HTTPS,然后跟用户之间保持明文 HTTP,用户看到的页面一模一样,内容却全被中间人看光了。

HSTS(HTTP Strict Transport Security)就是让浏览器记住「这个域名以后只准走 HTTPS」。一旦浏览器收到了带 HSTS 头的响应,在有效期内,它会在本地直接把所有 http:// 请求改写成 https://,连那一次明文请求都不会发出去,SSL Strip 就失去了入口。

它的响应头长这样:

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

三个部分要分开理解:

max-age 是浏览器记住这条规则多少秒。31536000 是一年。这里有个非常关键的细节:HSTS 的 max-age 是单向的。浏览器一旦记住了,即使你事后把服务器上的 HSTS 头删掉,在那一年到期之前,用户的浏览器依然会强制走 HTTPS。所以不要在测试阶段随手写一个很大的 max-age,否则一旦证书出问题,你自己和所有访问过站点的用户都进不去——浏览器连「继续访问(不安全)」的按钮都不给。

includeSubDomains 表示所有子域名也遵守这条规则。如果你的主站是 blog.example.com,而某个子域名 a.example.com 还在跑只支持 HTTP 的老系统,加上这个参数之后,那个子系统会立刻无法访问。加之前一定把所有子域名过一遍。

preload 是第三个可选参数,它本身不产生任何效果,只是「声明我愿意被加入浏览器内置列表」。真正的 preload 需要去 hstspreload.org 提交申请,通过后你的域名会被硬编码进 Chrome、Firefox、Safari 的源码里,用户第一次访问之前就已经强制 HTTPS 了。这是唯一能防住「首次访问」的手段,但代价是撤销非常麻烦——从列表里移除要等几个月甚至更久,而且各浏览器不同步。个人站不建议一上来就 preload,至少稳跑三个月以上再考虑。

二、Nginx 里的正确写法:只加在 HTTPS 的 server 块里

最容易犯的错误是把 HSTS 头加在 80 端口的 server 块里,或者干脆加在 http 全局块里。规范明确要求:浏览器只在通过 HTTPS 收到的响应里才会处理 HSTS 头。通过明文 HTTP 收到的 Strict-Transport-Security 会被直接忽略——所以加在 80 端口那块是无效的,只是给自己一种「配了」的错觉。

正确的结构是:80 端口只负责跳转,443 端口负责下发 HSTS。

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

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

    # ... 其余 location
}

这里那个 always 参数很重要,很多人不知道。Nginx 的 add_header 默认只在响应码为 200、201、204、206、301、302、303、304、307、308 时才添加响应头。也就是说,如果你的站点返回 404、403、500,这些响应里是没有 HSTS 头的。攻击者完全可以利用这一点:故意构造一个必然返回 404 的请求,拿到没有 HSTS 的响应来做降级。加上 always 之后,无论什么状态码都会带上这个头。

另一个隐蔽的坑:add_header 有「就近继承」的语义。如果某个 location 块里自己也写了 add_header,那么上一层的所有 add_header 都会被整体覆盖,不只是覆盖同名的那一个。典型场景:你在 server 层加了 HSTS,然后为了给静态资源加缓存又在 location 里写了 add_header Cache-Control ...——结果就是访问静态资源时 HSTS 头消失了。排查这类「首页有、图片没有」的问题时,第一件事就是搜一遍配置文件里所有的 add_header。

验证方法很直接,不要只看浏览器:

curl -sI https://example.com/ | grep -i strict-transport
curl -sI https://example.com/not-exist-page | grep -i strict-transport

第二条命令是检验 always 是否生效的关键——如果 404 响应里没有这个头,说明 always 没加或者被 location 覆盖了。

三、CSP:先观察,再收紧,绝不一步到位

CSP(Content Security Policy)解决的是另一个维度的问题:即使页面被注入了一段恶意脚本,浏览器也允许你提前声明「这个页面只准执行来自哪些来源的脚本」。它是 XSS 的最后一道防线,也是防止第三方 CDN 被挂马之后波及自身的重要手段。

很多人第一次配 CSP 的流程是这样的:搜一段看起来很强的策略,贴进 Nginx,刷新页面——然后站点全白,控制台一片红。因为你的站点大概率用了内联脚本、内联样式、第三方统计代码、字体 CDN、广告代码,每一项都会被默认策略拦下来。

正确的做法是先只用 report-only 模式,收集一段时间再收紧:

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; report-uri /csp-report" always;

Report-Only 模式下浏览器不会拦截任何东西,只是把所有「本来会被拦」的行为上报给你。跑上三五天,把正常访问、后台登录、评论提交、文章编辑这些路径都走一遍,你会拿到一份真实的需求清单,而不是靠猜。

收集报告需要一个接收端点。最省事的做法是在 Nginx 里把上报请求直接记进一个独立日志,不用写后端代码:

location = /csp-report {
    access_log /var/log/nginx/csp-report.log;
    return 204;
}

然后在上报日志里统计被拦的 URI 和指令,就能看出该给哪些来源开白。实际统计下来,自建站最常需要放行的通常是这几类:

一是内联脚本。很多老主题、统计代码、广告代码都是内联的。CSP 提供了 'unsafe-inline' 让内联脚本通过,但它也同时放行了攻击者注入的内联脚本,安全收益大打折扣。更讲究的做法是给内联脚本算 hash('sha256-...')或用 nonce,但要求你能动态生成页面里的 nonce——静态站或缓存很强的站做起来很别扭。个人站如果只是想防第三方脚本被篡改,保留 'unsafe-inline' 但严格限制 script-src 的来源域名,收益依然可观。

二是内联样式。几乎所有页面构建工具都会产生内联 style 属性,style-src 'unsafe-inline' 基本上跑不掉,不必为它纠结。

三是广告和统计。AdSense 需要放行 pagead2.googlesyndication.com、googleads.g.doubleclick.net 等一堆域名,还需要 frame-src。这块最稳妥的办法是照官方文档给的清单抄,不要自己猜。

四是图片。很多站点的图片走独立图床域名,img-src 里必须显式列出来,否则图片全部裂开——而且裂开的地方往往是文章正文,很难第一眼发现。

四、一套可以直接抄的起步策略

等 report-only 跑够了、白名单整理出来之后,把策略改成正式生效(去掉 -Report-Only),从最保守的版本开始,出问题再逐步放宽,而不是反过来。下面这个起步版本适合绝大多数自建内容站:

add_header Content-Security-Policy "default-src 'self'; \
  script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com; \
  style-src 'self' 'unsafe-inline'; \
  img-src 'self' data: https:; \
  font-src 'self' data:; \
  connect-src 'self'; \
  frame-src https://googleads.g.doubleclick.net; \
  frame-ancestors 'self'; \
  base-uri 'self'; \
  form-action 'self'; \
  object-src 'none'" always;

几个参数值得单独说:

frame-ancestors 'self' 是现代版的「防点击劫持」(防别人用 iframe 把你的站嵌进去做诱饵点击)。它取代了老的 X-Frame-Options,但为了兼容老浏览器,两个可以同时配。

object-src 'none' 禁掉 Flash、Java Applet 这类插件内容。这个年头没有任何理由保留它们,直接关掉。

base-uri 'self' 防止攻击者通过注入 <base> 标签把页面里所有相对链接劫持到自己的域名。这个小众但真实的攻击手法,很多人不知道。

注意 Nginx 配置文件里用反斜杠换行拼接长字符串是合法写法,但反斜杠后面不能有空格,否则会把空格拼进响应头里,某些浏览器会对畸形的头直接忽略整个策略。为了避坑,建议干脆写成一整行。

五、配完之后怎么证明它真的生效了

配置类工作最大的风险是「以为配了」。下面这套验证流程建议做成一次性的检查清单,每次改完都跑一遍。

第一步,直接看响应头,确认头和值都对。这里要注意 CDN——如果你的站前面有 Cloudflare 之类的 CDN,响应头可能是 CDN 加上去的,也可能是源站透传的,两者在变更后的生效时间不同。用 curl -sI 看时,如果看到 cf-cache-status: HIT,说明你拿到的是缓存响应,加个随机查询串再试:

curl -sI "https://example.com/?_=$(date +%s)" | grep -iE 'strict-transport|content-security'

第二步,看浏览器实际执行的结果。打开开发者工具的控制台,故意写一行内联脚本 eval("1+1")。如果 CSP 里没有 'unsafe-eval',控制台会立刻报「Refused to evaluate a string as JavaScript」——这说明策略真的在生效,而不是只发了个头却没被浏览器认。这是最可靠的验证方式。

第三步,确认 HSTS 被记住了。Chrome 里访问 chrome://net-internals/#hsts,在最下面的 Query HSTS/PKP domain 里输入你的域名,能看到 static_sts_domain 和过期时间就说明浏览器已记录。或者更简单:在地址栏手输 http://example.com,看它是否在发出请求之前就自动变成 https。

第四步,检查子域名。如果你加了 includeSubDomains,把所有还在用的子域名(图床、静态资源、API、邮件里的追踪链接)都访问一遍,确认没有哪个还在跑纯 HTTP。

六、几个真实踩过的坑

第一个坑是后台编辑器白屏。CSP 收紧之后,富文本编辑器普遍会挂——它们大量使用 innerHTML、动态脚本注入和 eval。这个不是策略写错了,而是编辑器本来就依赖这些能力。解决办法是给后台路径单独放宽,而不是为了后台把整站的策略降级:

location /admin/ {
    add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:" always;
}

但要记住前面说的「就近继承」——这个 location 里一旦写了 add_header,server 层那几条全都没了。如果后台也需要 HSTS,得在这里重复写一遍。

第二个坑是缓存了的旧页面。CSP 是随 HTML 一起下发的,如果页面 HTML 被 CDN 缓存了一天,你改了策略,用户拿到的还是旧页面的旧策略。改完 CSP 之后,别忘了把 HTML 的缓存刷一遍,否则会得出「改了没用」的错误结论。

第三个坑是把 HSTS 加到了临时测试域名上。测试用的 test.example.com 一旦被 HSTS 记住,事后想用 HTTP 调试会非常痛苦。测试环境不要加 HSTS,或者只给一个极短的 max-age(比如 300 秒)。

第四个坑是只配了主域名,没配裸域。用户在地址栏输的是 example.com,证书和配置却只覆盖了 www.example.com。这种情况下裸域要么直接连不上,要么跳转过程中那一跳是明文的。正确做法是两个域名都签进同一张证书(Let's Encrypt 支持多域名),两个 server_name 都在 443 块里列上,再选一个做 301 归一。

七、把这件事收进日常清单

安全响应头这种东西,配完就忘是常态,而它恰恰是最容易在换主题、加第三方脚本、改 CDN 设置时被悄悄破坏的一类配置。建议把它固化成一个每周跑一次的检查脚本:拉取首页响应头,检查是否包含 HSTS 和 CSP;再拉一篇文章页和一个 404 页,确认三条路径的头一致。任何一条不通过就邮件告警。脚本本身只有十几行,但它能在你完全没注意到的时候告诉你「站点已经裸奔三天了」。

回到最开始那句话:HTTPS 只是把传输通道加密了,它不负责告诉浏览器「以后只能走加密通道」,也不负责限制页面能加载什么。HSTS 和 CSP 补的正是这两个缺口,加起来配置不到二十行。真正花时间的不是写配置,而是验证——而且这份验证工作,值得被写成脚本反复执行,而不是靠记忆。

Last modification:October 18th, 2026 at 10:23 pm

Leave a Comment