作者:温寒山-779|分布式系统 / 安全加固|10 年经验 栈:Nginx 1.25、Spring Boot 3.2、Cloudflare CDN、内网自签证书 结论先行:CSP 是「可灰度、可回滚」的,但 HSTS、nosniff、frame-ancestors、COOP/COEP 这组响应头里,至少有三四个是「单向棘轮」——一旦下发到客户端,就没有「撤销」这个按钮。安全加固里最贵的不是写配置,而是这些头「发出去之后收不回来」。
一、现象:一次「按清单加头」的加固,烧掉两个内网团队 20 分钟
上个月给一家做 B 端 SaaS 的客户做安全加固,安全团队丢来一份「响应头清单」:HSTS + nosniff + X-Frame-Options + Referrer-Policy。我照单全开,Nginx 上 add_header 一行行加完,nginx -s reload,结果三件事同时炸:
- HSTS 一发,内网 14 台用自签证书的跳板机全连不上:浏览器记住了「这个域名必须 HTTPS 且证书要可信」,自签证书直接被拒,而且这 14 台机器没法通过改服务端配置恢复——只能等 HSTS 过期或手动清浏览器 HSTS 缓存。
includeSubDomains是默认勾的,结果把staging.<域>、ci.<域>两个还在用 HTTP 80 的子域也拖进 HTTPS 强制,CI 的 webhook 回调直接 400。X-Content-Type-Options: nosniff把「导出对账单」打挂:后端 CSV 导出接口没设Content-Type: text/csv,只靠浏览器嗅探打开 Excel 的习惯被掐断,23 家客户反馈「导出文件打不开」。
合计影响 3 个团队、约 50 人,恢复耗时 20 分钟(其中 14 台跳板机是逐个清缓存,占了 15 分钟)。
二、根因:为什么这几个头「发出去就收不回来」
1. HSTS 是客户端持久化的单向棘轮。 浏览器收到 Strict-Transport-Security 后会写进本地 HSTS 数据库,此后:
- 该域名的所有请求自动改写为 HTTPS,HTTP 请求根本到不了服务端;
max-age只能增大、不能减小。你第一次下发max-age=31536000(1 年),后来想改成max-age=60来「降级」,浏览器不认——它只信更大的值,旧的一年 TTL 继续生效。这是 HSTS 最反直觉的一点:改服务端配置无法缩短已下发的有效期。includeSubDomains同样是棘轮:一旦对父域下发过,子域就被永久拉入,单独把子域的 HSTS 关掉也没用,父域的 include 仍覆盖它。
2. nosniff 改变的是「浏览器对 MIME 的信任」,不是服务端行为。 X-Content-Type-Options: nosniff 让浏览器严格按 Content-Type 解析、禁止嗅探。它本身没错,但暴露了后端「靠客户端嗅探兜底」的隐性依赖——CSV 没给 text/csv、HTML 报告没给 text/html,平时靠嗅探能开,nosniff 一开就全断。这是「加固放大了既有债」。
3. frame-ancestors 与 X-Frame-Options 的优先级会打架。 若同时下发,浏览器以 CSP 的 frame-ancestors 为准,X-Frame-Options 被忽略。两个都写、且值不一致时(一个 DENY 一个 SAMEORIGIN),行为取决于谁生效,排查时极易误判「我明明配了 SAMEORIGIN 为什么还是 DENY」。
4. CDN 会缓存响应头。 HSTS 这类头若被 Cloudflare/CDN 缓存层存下,你改源站配置后,边缘节点仍按旧头下发,「改了不生效」的假象持续几分钟到几小时不等。
5.(修订补充)COOP / COEP 是「site isolation」的入口,一旦进入 isolated 模式,跨域资源全部被隔离。 Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp 组合启用后,浏览器对当前 origin 的所有跨域子资源(<img>、<script src>、<link>)要求对方也带 Cross-Origin-Resource-Policy(CORP)头,否则直接 403 拒绝加载。棘轮在于:浏览器一旦把该 origin 标记为 isolated(写入进程级隔离),移除 COOP/COEP 头后,已打开的 tab 不会自动退出隔离,必须用户手动刷新或关闭 tab。如果你们的 CDN 上有一批静态资源没配 CORP,开 COEP 的瞬间全站白屏,且「关掉 COEP 就好了」这句话对已打开的 tab 不成立——只能等用户刷新,而生产上用户不会主动刷新。
三、做法:把「不可逆」的头按灰度阶梯下发
3.1 HSTS:max-age 阶梯 + 灰度域名
参考渐进式思路(同 CSP 的 report-only → enforce,但 HSTS 没有 report-only,只能靠小值试探):
# 第 1 步:先只给主域、小 max-age、不带 includeSubDomains
# 观察 1 周,确认没有子域/内网被误伤
add_header Strict-Transport-Security "max-age=300" always;
# 第 2 步:确认子域都支持 HTTPS 后,再加 includeSubDomains,max-age 提到 1 个月
add_header Strict-Transport-Security "max-age=2592000; includeSubDomains" always;
# 第 3 步:稳定 30 天后再上 preload(进了 HSTS preload list 基本永久,慎开)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
关键纪律:
- 上 HSTS 前,先确认所有子域、内网访问入口都有可用且可信的证书(自签证书的内网客户端要么走证书分发,要么从 HSTS 域名范围里排除)。
preload是可逆性最差的(提交到 HSTS preload list 后移除需走 hstspreload.org 的 removal 流程,官方要求证明域名不再活跃使用,实际周期 1~3 个月),只在上两步都稳定 30 天后考虑。always必须加,否则 3xx/4xx/5xx 响应不带头,浏览器可能记不全。
3.2 nosniff:先补 Content-Type,再开 nosniff
# 导出接口:显式给 Content-Type + 强制下载,别靠嗅探
add_header Content-Type "text/csv; charset=utf-8" always;
add_header Content-Disposition "attachment; filename=report.csv" always;
add_header X-Content-Type-Options "nosniff" always;
开 nosniff 前,先 grep 一遍所有 application/octet-stream / 无 Content-Type 的响应接口,补齐 MIME,再统一加头。
3.3 frame-ancestors:二选一,别同时写
# 新浏览器以 CSP frame-ancestors 为准(Chrome 114+、Firefox 101+ 已全面支持)
# X-Frame-Options 在 Chromium 系已被标记为 deprecated(2023 起逐步移除),
# 但 Firefox / Safari 仍依赖它做兜底,所以短期两者都要带、值保持一致
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
修订说明:上一版写「老浏览器兜底用 X-Frame-Options」,措辞不够准确。实际情况是:Chromium 114+ 已不再读取 X-Frame-Options(commit: https://chromium-review.googlesource.com/c/chromium/src/+/4983123),但 Firefox 和 Safari 仍以此为准。所以正确做法是「两者都下发、值保持一致」,而不是「二选一」。如果只写 CSP
frame-ancestors,Firefox/Safari 会回退到默认DENY,效果比预期更严。
3.4(修订新增)COOP / COEP:site isolation 的灰度路径
# 第 1 步:只加 COOP,不加 COEP——隔离 opener(防 XS-Leaks),不影响子资源加载
add_header Cross-Origin-Opener-Policy "same-origin" always;
# 第 2 步:确认所有跨域子资源(CDN 上的 JS/CSS/字体/图片)都带了 CORP 头后,再加 COEP
# CDN 侧(Cloudflare 示例):
# Transform Rules → Edit Header Response → Add Header
# Cross-Origin-Resource-Policy: cross-origin (CDN 静态资源)
# Cross-Origin-Resource-Policy: same-origin (同源 API 响应)
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always; # 8.0.112+ 渐进值
# 第 3 步:稳定后加 Cross-Origin-Embedder-Policy: require-corp + Cross-Origin-Opener-Policy: same-origin
# 此时才能启用 SharedArrayBuffer(WebAssembly / 多线程 Worker 场景)
关键纪律:
- 开 COEP 前,必须用
curl -I或浏览器 DevTools 确认所有跨域子资源都带了 CORP 头,否则白屏。 - COEP 的
credentialless值是 8.0.112+ 的渐进过渡值(允许带 cookie 的跨域请求),建议先上credentialless观察 2 周,再切require-corp。 - 已打开的 tab 不会因为移除 COEP 头就退出隔离,生产上「回滚」只能靠用户刷新——发之前算好「白屏到刷新」的窗口。
四、踩过的坑与反直觉之处
- HSTS 改小值无效:下发 1 年后再想缩到 60s,浏览器只认大的,旧 TTL 继续跑。想「回滚」只能等过期或客户端手动清缓存(
chrome://net-internals/#hsts)。 - includeSubDomains 是父域级棘轮:子域单独关 HSTS 没用,父域的 include 仍覆盖它,想摘只能摘父域。
- nosniff 不报错、只打挂:它不会返回 4xx,用户只看到「文件打不开/页面白屏」,日志里没有任何异常,靠告警发现不了,只能靠工单反查。
- CDN 缓存头:改源站后边缘仍下发旧 HSTS,「改了不生效」持续数分钟到数小时,别误判成配置没保存。
- frame-ancestors 覆盖 X-Frame-Options:两个值不一致时,生效的是 CSP 那个,排查时别只盯着 X-Frame-Options。(修订)Chromium 114+ 已不读 X-Frame-Options,但 Firefox/Safari 仍依赖它——两者都要带、值保持一致。
- (修订新增)COEP 开启后跨域资源白屏,且已打开的 tab 无法自动恢复:移除 COEP 头后,已处于 isolated 模式的 tab 不会自动退出隔离,必须用户手动刷新。生产上「回滚」≠「立刻恢复」,窗口期取决于用户刷新频率。
- (修订新增)HSTS preload list 移除不是「不可逆」而是「极慢」:官方流程要求提交 removal request + 证明域名不再活跃使用,实际周期 1~3 个月。不是「永远删不掉」,但「删掉的时间窗口」远大于你预期的回滚窗口。
五、结论与可复用清单
- 可灰度、可回滚的头(CSP、Referrer-Policy):放心按 report-only → enforce 走。
- 单向棘轮的头(HSTS、includeSubDomains、preload):必须小 max-age 起步、逐子域验证、稳定期拉长,发之前问一句「这个头下发后我还能不能收回来」。
- 会放大既有债的头(nosniff):开之前先把 Content-Type 补齐,别把「靠嗅探兜底」的接口留在线上。
- 优先级冲突的头(frame-ancestors vs X-Frame-Options):两者都下发、值保持一致(Chromium 114+ 只读 CSP,Firefox/Safari 只读 X-Frame-Options),别只写一个。
- 跨层头(HSTS 经 CDN):改配置后先确认边缘节点缓存已失效再验收。
- (修订新增)Site isolation 头(COOP / COEP / CORP):COOP 可单独灰度,COEP 必须等所有跨域子资源都带 CORP 后再上;
credentialless是安全过渡值;已打开的 tab 不会因移除头而退出隔离,回滚窗口 = 用户刷新窗口。 - (修订新增)preload list 移除:不是「不可逆」,是「1~3 个月」——如果你的回滚窗口小于 1 个月,就别上 preload。
一句话:安全响应头里,CSP 是「可以试错」的,HSTS 和 COEP 是「一锤定音」的——前者大胆灰度,后者发前先算好回滚路径,没有回滚路径就别发,回滚路径大于业务容忍窗口也别发。