安全响应头实战:CSP与Permissions-Policy的边界

一个只改了一行的安全头配置

去年帮一位做旅游攻略的朋友看他的站,他抱怨说"这站我什么都做了,SSL 也上了,为什么还是被挂了一串莫名其妙的广告层"。我打开他的 nginx 配置,发现安全相关的指令写了三十多行:X-Frame-Options、X-Content-Type-Options、HSTS、CSP 全都有,看起来非常完整。但真正的问题不在缺哪一项,而在于这些头之间互相打架。

他写了 X-Frame-Options: SAMEORIGIN,同时 CSP 里的 frame-ancestors 又写成了 *。现代浏览器以 CSP 为准,于是"防点击劫持"这一层形同虚设。类似的问题还有 X-XSS-Protection: 1; mode=block——这个头在 2019 年之后被主流浏览器废弃,某些版本上反而会引入侧信道问题,属于"写了不如不写"。

这件事让我意识到,安全头这东西的难点从来不是"知道有哪些头",而是"知道每个头的边界在哪、什么时候会互相覆盖、什么时候该交给浏览器自己的默认行为"。这一篇就把我这两年反复调整下来的一套配置和判断逻辑写清楚,包括那些文档上不太提、只有实际踩过才知道的细节。

先把现状看清楚:三个探针

在看任何文档之前,先确认你的站现在到底发了什么头。很多"配置不生效"的排查失败,都是因为从头到尾没看过真实响应。

# 探针一:看响应头全貌,含重定向链路
curl -sSI https://example.com/ | grep -i -E 'strict|x-frame|content-security|referrer|permissions|x-content'

# 探针二:跟随跳转,看最终落地页的头
curl -sSIL https://example.com/ | grep -i -E 'strict-transport|content-security'

# 探针三:看 HTML 里有没有 meta 版本的头
curl -sS https://example.com/ | grep -ioE '<meta[^>]*(http-equiv|name)="[^"]*"[^>]*>' | head -20

三个探针分别回答三个不同的问题:第一,源站发了什么;第二,经过跳转和 CDN 之后最终页面收到什么;第三,页面内有没有用 meta 标签"补发"头。第三个尤其重要——meta 版本对 X-Frame-Options、HSTS 完全无效,很多人把 CSP 写在 meta 里以为万事大吉,结果 frame-ancestors、report-uri、sandbox 这几个指令在 meta 里是被忽略的。

如果 CDN 上开了"响应头改写"功能,探针一和探针二的结果会不一样。这时要记住一条:两处都配置同一类头,重复出现的那一项就是隐患。浏览器对重复的 X-Frame-Options 处理得很粗暴——两个值冲突时直接按最严格的拒绝或者干脆全部忽略,具体行为各浏览器不同,你会得到一个"时好时坏"的诡异现象。

X-Frame-Options 到 CSP frame-ancestors 的迁移

X-Frame-Options 只有三个合法值:DENY、SAMEORIGIN,以及早已被废弃的 ALLOW-FROM uri。它的设计缺陷很明确:不支持多来源白名单,也不能细分路径。所以如果你的站需要允许某几个合作方嵌入,XFO 根本做不到,只能靠 CSP。

正确的过渡姿势是两条都给,且语义保持一致:

add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self';" always;

现代浏览器读 CSP 的 frame-ancestors,老浏览器读 XFO,两边结论一致就不会出现"某个浏览器上能嵌、另一个不能"。反过来,如果你只想要"完全禁止被嵌"(比如后台管理页面),用 frame-ancestors 'none' 配 DENY。

这里有个很典型的坑:给全站加 frame-ancestors 'none' 会打挂站内自己的 iframe。我见过一个后台管理系统,文件管理器用 iframe 内嵌预览页,加了全站 CSP 之后"上传成功但预览空白",查了两小时才想到安全头。稳妥做法是分层配置:

# 全站默认
add_header Content-Security-Policy "frame-ancestors 'self';" always;

# 后台:禁止任何方式嵌入
location ^~ /admin/ {
    add_header Content-Security-Policy "frame-ancestors 'none';" always;
    add_header X-Frame-Options "DENY" always;
}

注意 add_header 的继承规则:只要一个 location 块里出现了任意一条 add_header,上一层的所有 add_header 都不再继承,除非用 always...其实 always 只影响状态码覆盖范围,不影响继承。这个"父层头全丢"的行为是 nginx 里最容易造成"某个路径的安全头莫名消失"的原因。所以更稳的做法是把公共头抽成 include 文件,在每个需要自定义头的 location 里显式 include 一次。

CSP:从"不敢开"到"分阶段收紧"

CSP 是安全头里威力最大、也最容易把站弄坏的一个。我的建议很务实:不要试图一步到位写出一份完美的 CSP,那基本等于自找加班。分三步走。

第一步,先只报告不拦截。用 Content-Security-Policy-Report-Only 头,配一个收集端点,跑一到两周:

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

报告端点的最小实现是一个接收 POST JSON 的脚本,按 blocked-uri 聚合计数。跑一周你就会发现:真正拦不掉的只有统计代码、广告位、以及主题模板里内联的事件属性(onclick= 这类)。这三类恰好都是 'unsafe-inline' 存在的原因。

第二步,处理内联脚本。这一步是全部工作量的大头。'unsafe-inline' 一旦保留,CSP 对 XSS 的防护基本归零——攻击者注入一个 <script> 就能跑。收掉它的两条路各有代价:

  • nonce 方案:服务端每次响应生成随机 nonce,把带 nonce 的 script 白名单放行。缺点是所有页面必须动态渲染,整页静态缓存(包括 Nginx 整页缓存和 CDN 页面缓存)全部要跟着改,因为缓存下来的 HTML 里 nonce 是旧值、和响应头对不上。这也是很多站长最终放弃 nonce 的直接原因。
  • hash 方案:把每段内联脚本的 SHA-256 写进 CSP。缺点是一改脚本就得改头,且内联脚本数量多时头部会非常长。

对于内容站,我实际采用的是一条折中路线:对外页面用 hash,后台用 nonce,静态资源目录干脆全部外链化。文章页里如果确实需要内联的一小段(比如广告位初始化),把它的 hash 固定进配置;主题里的事件属性统一改成 addEventListener,一次改完长期受益。

第三步,按资源类型收紧。顺序上先 img-src、style-src 这类破坏面小的,最后才动 script-src。写的时候记住两个默认值陷阱:不写 default-src 就等于没限制;写了 default-src 'self' 而没有单独指定 img-src,那么所有外链图片(图床、CDN、第三方统计的像素)都会被拦——这是"图片突然全裂"最常见的原因。

Referrer-Policy:别在不知不觉中泄露路径

浏览器默认的 referrer 策略近两年逐步收紧到 strict-origin-when-cross-origin,这已经是个相当不错的默认值。但如果你用的是较老的浏览器,或者站内有些页面的 URL 里带敏感参数,就值得显式声明。

add_header Referrer-Policy "strict-origin-when-cross-origin" always;

不同取值的行为差异值得搞清楚:

  • no-referrer:完全不发 Referer。最安全,但会打断一部分依赖来源判断的防盗链、以及部分统计工具的来源归因。
  • same-origin:同站发完整 URL,跨站不发。适合站内路径不希望外泄、同时不太在乎外部统计的场景。
  • strict-origin-when-cross-origin:同站发完整 URL,跨站只发协议+域名(降级到 HTTPS 才发,HTTP 目标不发)。绝大多数站的最佳选择。
  • unsafe-url:永远发完整 URL。除了排障,没有理由用它。

真正需要小心的是"路径即隐私"的那类页面:密码重置链接、带邀请码的注册页、带订单号的查询页。这些页面只要被外链(哪怕是一张外链图片)带上完整 Referer,链接就可能流到第三方日志里。对于这类路径,单独设 no-referrer 是最省事的防御,比改造 URL 结构快得多。

location ^~ /reset/ {
    add_header Referrer-Policy "no-referrer" always;
    include /etc/nginx/snippets/security-headers.conf;  # 其余头仍需显式 include
}

Permissions-Policy:把用不上的硬件权限关掉

这个头的前身叫 Feature-Policy,语法改过一次,照抄旧文章里的写法现在全部无效——旧语法是 Feature-Policy: geolocation 'none',新语法是 Permissions-Policy: geolocation=()。两者不兼容,浏览器不会给你报错,只是静默忽略。

add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=()" always;

空括号 () 表示"任何来源都不允许",包括自己。如果站内确实要用某个能力(比如上传头像要用摄像头拍照),写成 camera=(self)。

这个头的实际价值有两块:一是减少第三方 iframe(尤其是嵌入的视频、地图、广告)顺手拿权限的可能;二是对站内被 XSS 注入的脚本形成一层额外限制——即使脚本跑起来了,也拿不到摄像头和定位。对内容站来说,除了 fullscreen=(self) 可能需要(视频全屏播放),其余基本都可以关。

HSTS:一旦上了就很难回头,先算清楚

HSTS 告诉浏览器"以后访问我这个域名一律走 HTTPS,不要再试 HTTP"。配置本身很简单:

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

但它有三个不可逆的后果,必须在按下回车前想清楚:

  1. max-age 写下去就等于承诺。浏览器把这条策略缓存一年。如果在这一年里你的证书过期、或者某个子域还没配 HTTPS,用户会看到无法跳过的错误页,且没有"继续访问"的选项。唯一的解法是让用户手动清浏览器 HSTS 缓存,这在实际运营中基本做不到。
  2. includeSubDomains 会波及所有子域。包括你可能早就忘了的测试环境 test.example.com、内网穿透用的 nas.example.com。上线前必须把所有子域列一遍,逐个确认 443 通且有有效证书。
  3. preload 是单向门。把域名提交到浏览器厂商的预置列表后,删除要等几个月随浏览器版本更新生效。除非你的站已经稳定 HTTPS 一年以上、所有子域都就绪,否则不要碰。

我建议的上线节奏是:先 max-age=300 跑一天,确认所有子域正常;改成 86400 跑一周;最后才上 31536000。出了问题趁 max-age 小的时候还能等它自然过期,比事后救火便宜太多。

CORS 与安全头不是一回事

这一节是很多人混淆的地方。CORS 头(Access-Control-Allow-Origin)不属于"安全加固"范畴,它是授权机制,不是限制机制——浏览器执行同源策略本身就是默认行为,CORS 头的作用是"放宽"这个默认。

常见误区有三个:

  • 以为加了 CORS 就安全了。恰恰相反,Access-Control-Allow-Origin: * 配 Access-Control-Allow-Credentials: true 是明确被浏览器禁止的组合(会直接报错),而单独放开 * 则意味着任何站点的脚本都能读你的公开接口响应。
  • 把 CORS 头加在 / 上导致所有静态资源都带 *。这本身不致命,但配合前面的 CSP 会让人误以为"跨域已放开"。
  • 忘了 Vary: Origin。当你的后端按请求的 Origin 动态回显不同值时,如果不发 Vary: Origin,中间缓存(CDN、Nginx proxy_cache)会把 A 站点的响应缓存下来喂给 B 站点,造成"跨域时好时坏"。

内容站如果没有任何跨域 API,最干净的做法是一条 CORS 头都不发。默认的同源策略就是最严格的配置。

配置落地:一份可直接用的片段

把上面讨论的东西整理成一份可复用片段,放在 /etc/nginx/snippets/security-headers.conf:

# 安全响应头公共片段 v2
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Strict-Transport-Security "max-age=86400; includeSubDomains" always;

然后在 server 块里 include 一次,并在每个自定义了 add_header 的 location 里再 include 一次。上线前用 nginx -t 检查语法,再 nginx -s reload 平滑重载。

注意上面 CSP 里还留着 'unsafe-inline'——这是第一阶段的形态,先确保不破坏站点,再按前面的三步骤收紧。不要因为"看起来不够严格"就不上线,一份有效执行的不完美 CSP,比一份停留在文档里的完美 CSP 有用得多。

上线后怎么验证没把站弄坏

改安全头最容易出的问题是"某个页面白了、某个功能静默失效",而且往往过几天才被用户发现。三道验证建议固定进发布流程:

验证一,头是否真的发出去了。用前面探针一,确认每个头只出现一次。重复出现说明 CDN 和源站都配了。

验证二,浏览器控制台有没有 CSP 违规。打开三个有代表性的页面(首页、文章页、后台列表页),看 Console 里的 Refused to load 和 Refused to execute。这一步只有人工能做,自动化测不出来。

验证三,登录和表单是否还能用。CSP 的 form-action 写错会让表单提交被拦,且表现是"点了提交没反应",没有任何报错弹窗。后台登录页一定要手动试一次。

另外补一个自动化的低成本手段:用 headless 浏览器跑三个页面并 dump console 日志,把 Refused to 作为失败判据,接进夜间定时任务。这样第二天早上就能知道昨晚的配置变更有没有引入新违规,而不是等用户来报。

几个不值得做的事

最后说点反向的经验,避免在安全头上过度投入:

  • 不要为了"安全评分"而堆头。各类在线扫描工具给的分和实际风险只有弱相关。把一份 CSP 收紧到能防住 XSS,比同时挂十个评分工具喜欢的头更实在。
  • 不要保留 X-XSS-Protection。它已被主流浏览器移除或废弃,部分实现还会引入信息泄露,删掉即可。
  • 不要给 API 响应加 CSP。application/json 响应加 CSP 毫无意义,纯属噪声,而且会让排查真实问题时更难看清。
  • 不要把安全头当成唯一防线。它们防的是"浏览器侧的执行与加载",防不了服务端的注入、越权和文件上传。头配得再全,一个 eval($_POST['x']) 就能绕过全部。

回到开头那个朋友的站。最后实际的改动是:删掉 X-XSS-Protection,把 CSP 里的 frame-ancestors 从 * 改成 'self',补上 nosniff 和 Referrer-Policy,Permissions-Policy 关掉全部硬件权限,HSTS 先设 86400。总共不到十行,改完后台那个"偶发被嵌广告"的问题就没再出现过。安全头这件事,收益最大的永远是最基本的那几项,而不是最长的那份清单。

Last modification:October 26th, 2026 at 10:27 pm

Leave a Comment