白名单上明明写着那个域名,浏览器还是把它拦了,而违规报告指着的正是它
本文目录
- 一份第三方白名单,是从哪一天开始过期的?
- 四种漂移形态,都不会经过你的审批
- 失败的姿势为什么这么难被发现
- 报告里指着白名单上那个域名,是浏览器搞错了吗?
- 同一次拦截,两个地方说的不是同一个域名
- 文档没告诉你这件事
- 为什么前端监控一条都没报上来?
- 唯一说真话的那个接口,默认没人在听
- 按官方推荐把两个上报指令都写上,为什么一份报文都收不到?
- 零告警被读成零风险
- 白名单的匹配规则,和你以为的一样吗?
- 端口和协议这两栏,最容易在迁移里留下地雷
- 加上strict-dynamic,能不能一劳永逸?
- 代价写在同一段文档里,但排在下一屏
- 顺便说,那份白名单的安全收益本来就没你以为的高
- 运维加的那条“更严”的响应头,做了什么?
- 两条策略是交集,不是并集
- 控制台指着的是第二条,查配置的人盯着第一条
- 现网这些白名单,实际长成什么样?
- 覆盖率与写法分布
- 清单有多长,又有多少条是活的
- 写了但没用到的,占了四分之三
- 十个站的首页,当场就有东西被自己的策略拦掉
- 这份清单要怎么养,才不会反过来咬人?
- 先把眼睛装上
- 再把清单本身管起来
- 最后是路线选择
- 常见问题解答
- 页面返回200、服务端日志干净,怎么快速确认是不是被策略拦了?
- 违规报告里指着的域名明明在白名单里,为什么还被拦?
- 加了strict-dynamic之后,原来白名单里的第三方为什么全挂了?
- 白名单里写了根域名,为什么它的子域还是加载不了?
- 为什么第三方脚本在测试环境好好的,上生产就被拦?
- 配了违规上报却一份报文都收不到,是接收端的问题吗?
- 白名单里的条目能不能定期自动清理?
- 这套东西对SEO有直接影响吗?
- 权威参考资料
摘要:响应头里那份
script-src第三方域名白名单,记录的是你写下它那一天、第三方所使用的域名。第三方换域名、加边缘节点、被收购改名、在链路中间加一次跳转,都不会通知你,清单于是从写下的第一天起就开始过期。过期的后果是组件静默消失:页面返回200,服务端日志干干净净,前端监控收到一个没有任何信息的空事件,而按官方推荐写法配好的违规上报,实测一份报文都收不到。本文用四主机名双端口的本地HTTPS实验台跑了25组判决矩阵,把漂移的每一种形态、失败的每一种姿势、上报的每一种写法逐条测了一遍,再对149个站的首页做了一次白名单体检:53个站在发这行头,白名单里840个具体主机名有46个已经解析不出来,其中包括早已关停的分享服务、被收购后连公司都没了的加速厂商,以及5个被当成域名写进去的浏览器扩展ID。
一份第三方白名单,是从哪一天开始过期的?
故障是从客服工单开始的。用户说结账页上的分期付款选项不见了,换个浏览器也不见。运维查服务器,那个页面全程返回200,访问日志里连一条4xx都没有;后端查订单表,走另一条支付通道的单子照常进来,只是分期那条通道两天没有一笔。这类“某个支付选项静静消失”的故障不会被算进任何一份弃单分析里,结账页放弃率的9个真实成因那份诊断清单里的九种,也都默认页面是完整渲染出来的。
真正的原因写在响应头里。这一层向来是改一行影响全站的地方,HTTP响应头的SEO机制那篇按字段整理过一轮,而这次的肇事者是三周前一次安全加固留下的:script-src后面跟着一串第三方域名,其中就有那家分期服务商。三周里没人动过这行配置。动的是对方——他们把脚本从主域名迁到了一个新的加速域名上,发了邮件通知,收件人是对接的技术负责人,那封邮件里也没有出现“CSP”这三个字母。
这类故障的共同形状是:配置没变,环境变了。白名单是一份静态清单,它记录的是某年某月第三方所使用的域名;而第三方的域名是活的,会迁移、会加节点、会因为公司被收购而整体换掉。清单不会自己跟上,也没有任何机制在它落后时提醒你。
四种漂移形态,都不会经过你的审批
为了把每一种形态单独测清楚,保哥在本地起了一套真实的HTTPS实验台:四个主机名跑在同一个进程上,按Host头分流——top是顶层站点,a是写进白名单的那个第三方域名,b是漂移之后的新域名,s1.a是a的子域;另外把同一份证书挂在两个端口上,用来单独测端口这一项。证书自签,浏览器忽略证书错误,但连接本身是真的,走的是完整网络栈。
这一步不能省。上一批做Clear-Site-Data时试过用测试框架的路由拦截伪造响应头,结论整组作废——响应头触发浏览器行为这类实验,必须让请求真的从网络栈上走一遍。相关的坑在登出清数据那篇里写过一次。
把资源指向不同的主机名,白名单一个字不改,四种漂移形态的结果如下。两个浏览器内核跑出来的判决完全一致,除了标注的那一项。
| 漂移形态 | 白名单里写的 | 资源实际在 | 判决 |
|---|---|---|---|
| 基线(没有漂移) | a主机名 | a主机名 | 加载 |
| 第三方换了域名 | a主机名 | b主机名 | 拦下 |
| 链路中间加了跳转 | a主机名 | a跳转到b | 拦下 |
| 第三方挪到了子域 | a主机名 | s1.a子域 | 拦下 |
| 第三方启用了新端口 | a主机名,端口18443 | a主机名,端口18444 | 拦下 |
四种形态里,只有第一种是运维自己能预料到的。后面三种发生在第三方那一侧,不需要通知你,通常也确实不会通知——加一个边缘节点、给某个区域启用独立子域、把回源地址换个端口,这些在对方的运维视角里是常规操作,跟他们的客户站点的响应头没有任何关联。
失败的姿势为什么这么难被发现
更麻烦的是失败之后什么都不会发生。被拦下的脚本不会执行,于是它本该渲染的那块区域保持空白;页面其他部分完全正常,用户看到的是一个“少了点什么”的页面,而不是一个报错页。这种页面在、内容不在的白屏最难查,记事本BOM导致网页白屏那份排查指南里五种破坏现场的共同点也是这个——浏览器不报错,只是少做了一件事。框架祖先策略那篇里记过同一种形状:服务器全程200、订单已入库,用户眼前那块区域自始至终是空白。
三个本该报警的地方同时哑火。服务端不知道——拦截发生在浏览器里,请求根本没发出去。前端监控不知道——原因下一节展开,简单说就是它收到的错误事件被剥得只剩一个布尔值。违规上报不知道——按官方文档推荐的写法配置,实测一份报文都收不到,这一节留到后面单独说。
于是唯一的信号来源是用户投诉,而用户投诉的措辞永远是“结账页有问题”,不会是“某某域名的脚本被策略拦了”。工单从客服流到前端,前端看页面没报错,流到后端,后端看接口全通,最后才有人想起去看响应头。这条路径平均要走多久,取决于那个组件在多少人的视野里——如果它是收银台,几个小时;如果它是评价星标或者推荐位,可以躺上好几周。把这条路径缩短的办法之一是让客服侧的描述更可用,把工单沉淀成帮助中心内容那份协作账本里的第一个动作,就是给工单加上可复现的字段。
报告里指着白名单上那个域名,是浏览器搞错了吗?
漂移的四种形态里,“链路中间加了跳转”这一种最值得单独拆开讲,因为它会给排查的人一份自相矛盾的证据。
场景是这样的:白名单里写着a主机名,页面上的脚本标签指向的也是a主机名,但a收到请求后返回一个302,把浏览器指向b。最终被执行的脚本来自b,而b不在白名单里,所以浏览器拦下了它——这一步符合直觉。不符合直觉的是它怎么报这件事。
同一次拦截,两个地方说的不是同一个域名
监听securitypolicyviolation事件,拿到的blockedURI字段是a主机名的那个原始地址,也就是白名单里明明白白写着的那一个。而浏览器控制台里同一次拦截打出的错误信息,说的是b的地址。两个引擎在这一点上表现一致。
这意味着什么?如果排查的人依赖上报系统或者页面内的事件监听,他拿到的报告会指着一个白名单里已经写了的域名,说它违反了策略。合乎逻辑的下一步是核对配置——配置没问题;再核对拼写——拼写也没问题。除非有人打开控制台肉眼看那行红字,否则真正的肇事域名b不会出现在任何一份自动化的证据里。
这不是缺陷,是规范里写死的隐私设计。W3C的CSP规范在这一步专门留了一句注解:
We use request's url, and not its current url, as the latter might contain information about redirect targets to which the page MUST NOT be given access.
页面无权知道跳转的终点在哪里——否则任何一个页面都能靠加载一个跨源资源、读违规报告,来探测别人的重定向规则。为了堵住这个信息泄露,报告里只能给出请求发起时的那个地址。安全上完全正确,排查上就成了一份指向错误的证词。
文档没告诉你这件事
顺手查了一下开发者手册里blockedURI这个属性的说明,原文只有一句:表示被阻止的那个资源的地址。没有提重定向,没有提它在重定向之后指的是发起地址而不是终点。规范里写了原因,属性文档里没有转述——而排查的人第一时间会去看的正是属性文档。
这条经验可以推广:凡是一个字段在正常路径和异常路径下含义不同,文档只讲正常路径的那一半,异常路径就是排查的黑洞。跳转在第三方资源里太常见了,负载均衡、区域调度、灰度切换,全都靠302实现,所以这个“异常路径”其实是日常路径。
为什么前端监控一条都没报上来?
大部分团队的前端监控是这么接的:挂一个全局错误处理函数,把捕获到的错误连同堆栈发回自己的接口。这套东西对付脚本内部的报错很称职,对付被策略拦下的脚本,交出来的是一份空白笔录。
实验台上把五种加载方式各测了一遍,被拦之后各自的表现是这样的。
| 加载方式 | 被拦后的表现 | 能不能拿到被拦的域名 |
|---|---|---|
| 脚本标签的onerror | 触发了,但事件对象上只剩一个isTrusted属性,消息是空字符串 | 拿不到 |
| 全局onerror处理函数 | 根本不触发 | 拿不到 |
| fetch请求 | 抛TypeError,消息是Failed to fetch(另一个引擎写作NetworkError when attempting to fetch resource) | 拿不到 |
| XHR请求 | 走onerror分支,状态码是0 | 拿不到 |
| 图片标签 | 走onerror分支,自然宽度为0 | 拿不到 |
| 安全策略违规事件 | 字段完整,含指令名、策略原文、处置方式 | 拿得到 |
前五行的表现,跟域名解析失败、连接超时、返回404、跨源检查不通过,是一模一样的。fetch那一行尤其要命:Failed to fetch这句话在网络断了的时候会出现,在跨源响应头缺失的时候会出现,在被策略拦下的时候也会出现,三种原因、一句话。集成方的代码通常在这里写一句“网络异常,请稍后重试”,于是一个配置问题被伪装成了网络问题,用户重试一百次也不会好。
上一批做权限策略的时候撞到过同构的一条:摄像头被策略禁掉时抛出的错误名,跟用户手动点了“拒绝”时抛出的错误名完全相同。那篇文章里把它总结成“把配置问题伪装成用户行为问题”。这一批是同一个毛病换了个位置:把配置问题伪装成网络问题。
唯一说真话的那个接口,默认没人在听
表格最后一行是唯一的例外。浏览器在拦下资源时会派发一个专门的违规事件,字段相当齐全:被拦地址、生效的指令名、完整的策略原文、是拦截还是仅上报、来源文件和行号。想拿到它只需要一行监听代码。
问题在于这行代码不在任何一个监控SDK的默认配置里。它不是标准错误流的一部分,是一个独立的事件类型,名字也跟错误不沾边。结果是:真相一直在往外播,而全场没有一个接收器调到那个频道。
这里可以补的动作很朴素:在现有的监控初始化代码里加一条监听,把被拦地址和指令名一起送回自己的接口。它跟你已有的错误上报走同一条管道,不需要额外的基础设施,只是要有人想到去加。做前端和做运维的人之间,这类“谁也没觉得该自己加”的缝隙特别多,跟后端对SEO动作点的那份账本里讲过怎么把这种缝隙变成一条有主人的清单。
按官方推荐把两个上报指令都写上,为什么一份报文都收不到?
策略本身带上报能力:在策略里写一个上报地址,浏览器每拦一次就往那个地址投一份JSON。听上去这就是解决前一节问题的正解——不用改前端代码,运维在响应头里加一句就行。
老写法是report-uri,直接跟一个地址。新写法是report-to,跟一个端点组的名字,具体地址另外用一个响应头声明。官方文档明确说老的已经废弃,并给出了迁移期的推荐做法:
The report-to directive is intended to replace report-uri, and in browsers that support report-to, the report-uri directive is ignored. […] However until report-to is broadly supported you can specify both headers as shown.
两个都写,新浏览器走新的,老浏览器走老的,看起来滴水不漏。实验台上把四种写法各跑一遍,接收端就是实验台自己的一个接口,页面加载后等90秒、关掉页面、再开一个同源页面重新导航——把规范里所有可能触发投递的时机都走了一遍。结果如下。
| 策略里的写法 | 额外发的端点声明头 | 90秒内收到的报文 |
|---|---|---|
| 只写report-uri | 不需要 | 1份,10秒内到达 |
| 只写report-to | Reporting-Endpoints | 0份 |
| 只写report-to | Report-To(旧的JSON格式) | 0份 |
| 两个都写 | Reporting-Endpoints | 0份 |
第一行是阳性锚点,它证明接收端、证书、网络路径全都正常——同一个接口,换成老指令就能秒收。第二第三行说明新机制在这个环境里确实投不出来。第四行才是真正要命的:单写老指令能收到,两个都写就一份也收不到。
原因文档里已经写了,只是没人会把那句话读成一个警告:支持新指令的浏览器会忽略老指令。而支持不等于送得到——它认得这个指令、于是把老指令关掉,然后自己一份也没投出来。两个都写不是双保险,是让新的那个把老的那个挤下去,再一起沉默。
零告警被读成零风险
这条链路上最贵的不是那几份丢掉的报文,是它给人的错觉。上线一套新策略的标准流程是先用仅上报模式观察一段时间,看看会拦掉什么,确认没有误伤再切成拦截模式。如果观察期收到的是零报文,得出的结论就是“没有任何违规,可以放心切换”。
实验台上单独验过:仅上报模式本身工作正常——那一组里被拦的脚本确实照常执行了,说明“只观察不拦截”这一半是对的。错的是另一半,观察的结果压根没送出来。上一批做权限策略时也遇到过完全相同的局面,四种上报写法全是零报文,而同一个页面、同一个接收地址,老机制立刻收到一份格式完整的报文。两批下来的结论一致:这条观察链路默认是断的,而它断掉的方式恰好是返回一个看起来最令人安心的数字。
可以做的事有两件。一是别只信上报,观察期同时用真实浏览器把主要页面跑一遍,直接读控制台,做技术栈检测那类工具时用的也是同一套思路——眼见的证据比等着别人送上门的证据可靠。二是给上报通路自带一个阳性锚点:随便找个页面故意触发一次必定违规的加载,如果连它都收不到报文,说明问题在通路而不在策略。
白名单的匹配规则,和你以为的一样吗?
前面几节讲的是清单跟不上变化。这一节讲另一半:就算第三方一动没动,清单本身也可能从写下的那一刻就是错的——因为写清单的人对匹配规则的理解,和浏览器的实现之间有几处系统性的错位。
五组单变量实验,每组只改白名单里的一个部分,资源那边一个字不动。
| 白名单里写的 | 资源实际在 | 判决 | 写清单的人通常以为 |
|---|---|---|---|
| 星号加点加根域 | 根域本身,不带子域前缀 | 拦下 | 会覆盖根域 |
| 星号加点加根域 | 两层子域,比如s1.a那种 | 放行 | 只覆盖一层 |
| 裸的主机名,不带星号 | 它的子域 | 拦下 | 可能覆盖子域 |
| 主机名不写端口 | 非默认端口 | 拦下 | 端口无所谓 |
| 协议写成http | 同一主机名的https | 引擎之间结论相反 | 会自动升级匹配 |
前三行合起来是同一个知识点的两面:星号管子域不管自己,不带星号管自己不管子域。想把根域和它下面所有子域一起放行,必须两条都写。这条规则本身没什么争议,但它和证书里的通配符规则长得很像、行为却不一样——证书里的通配符只能覆盖一层,这里的通配符能跨任意层。习惯了申请证书的人按证书的心智模型来读这份清单,会在两个方向上同时判断错。
端口和协议这两栏,最容易在迁移里留下地雷
第四行的坑在于省略。写清单时只写主机名不写端口,读起来像是“这个主机名下随便哪个端口都行”,实际含义是“这个协议的默认端口”。平时看不出来,因为大家都用默认端口;一旦第三方给某个服务启用了独立端口,白名单里那条早就写好的记录就不再匹配了。
第五行是本批唯一一处两个引擎结论相反的地方。白名单里留着http://开头的条目、资源实际走https://,一个引擎拦下,另一个放行。规范里确实有协议升级匹配的规则,但它是成对生效的,原文举的例子是:
the source expression
http://example.com:80will match bothhttp://example.com:80andhttps://example.com:443.
协议和端口一起从不安全的那一组升到安全的那一组。如果条目上带的是一个非默认端口,这个升级配对就不成立,严格实现的引擎会判不匹配,宽松实现的引擎照放。这意味着同一份白名单,在两个浏览器上能得出两种结果,而“我在浏览器上验过了”这句话在这里失去意义。
这一栏的现实场景非常具体:站点从http迁到https的时候,页面里的资源地址都会被顺手改掉,响应头里那份白名单常常留在原地。全站迁HTTPS那篇列过一份迁移清单,这一条值得补进去——迁移检查表里通常有跳转、有站点地图、有内部链接,很少有人想到还有一行响应头。
加上strict-dynamic,能不能一劳永逸?
被白名单折磨过几次的团队,迟早会查到官方推荐的另一条路:不再逐个列域名,改成给页面里可信的脚本发一个一次性随机值,再加一个关键字,让这些可信脚本动态加载的东西自动获得同样的信任。这条路确实能解掉一大类问题——第三方脚本自己再去拉别的资源,不用你事先知道它会拉什么。
实验台上把这一组测清楚了:不加那个关键字时,第三方脚本a自己动态插入的第二个脚本b被拦下;加上之后,b顺利执行。到这里都符合预期。
代价写在同一段文档里,但排在下一屏
问题出在同一组实验的另一半:加上那个关键字之后,白名单里的a本身,通过页面上一个普通脚本标签加载,反而被拦了。控制台打出的错误信息里,被违反的策略原文明明白白列着a的地址。
官方文档写得很清楚,只是这句话排在关键字说明的后半段:
If this keyword is present in a directive, then the following source expression values are all ignored: host-source, scheme-source, 'self', 'unsafe-inline'.
也就是说,一旦启用这个关键字,你辛苦维护了两年的那份域名清单,连同'self',全部作废。策略从“列举可信域名”整个换轨成“标记可信脚本”,两套机制不叠加,后者直接顶掉前者。
这在设计上完全合理——既然信任由随机值传递,域名清单就是多余的,留着反而会被绕过。危险的是升级路径:运维看到“能解决动态加载问题”,把关键字加进现有策略、白名单原样保留,测试页面上主流程一切正常(因为主流程的脚本大多是带随机值的内联或首方脚本),而所有靠白名单放行的第三方组件在这一刻集体停摆。而且这次连改动量都很小——就多了一个单词。
顺便说,那份白名单的安全收益本来就没你以为的高
既然聊到取舍,有一份数据值得摆出来。Google的安全团队分析过约168万个主机上的2.6万条策略,结论相当难看:
94.68% of policies that attempt to limit script execution are ineffective. […] 14 out of the 15 domains most commonly whitelisted for loading scripts contain unsafe endpoints; as a consequence, 75.81% of distinct policies use script whitelists that allow attackers to bypass CSP.
最常被写进白名单的那15个域名里,14个含有可以被利用绕过策略的端点——这些域名恰恰是标签管理、广告、统计这类几乎人人都会加的服务。官方开发者文档也用了很直白的措辞,说基于域名清单的策略在大多数配置下可以被绕过。
把这两件事放在一起看,白名单这套东西的账是这样的:安全上的收益接近于零,运维上的成本却是持续的,而且成本以线上故障的形式支付。这不是说立刻该把它全删了——它对付一部分注入场景仍然有用,也常常是合规检查表上的硬指标——而是说,如果你正在为维护这份清单投入人力,投入产出比值得重新算一次。
运维加的那条“更严”的响应头,做了什么?
还有一类事故跟第三方完全无关,是自己人造成的。
这类事故在别的安全响应头上也发生过。开启HSTS那篇实战之所以要准备两条回滚路径,就是因为响应头一旦发出去、被浏览器记住,改回来比加上去难得多。
场景很常见:应用层已经在发一条策略了,是前端同学随着组件一个个接入慢慢攒起来的。某次安全审计之后,运维在接入层加了一条自己的策略,内容更规范、更严格——不是替换,是新增一行。两条策略从此并存在同一个响应里。
两条策略是交集,不是并集
实验结果没有悬念,但值得看清楚形状。
| 第一条策略允许 | 第二条策略允许 | 资源在 | 判决 |
|---|---|---|---|
| a主机名 | b主机名 | a | 拦下 |
| a主机名 | b主机名 | b | 拦下 |
| a主机名 | 全部放行 | a | 放行 |
| a主机名 | a主机名 | a | 放行 |
前两行说明了一切:一个资源必须同时通过每一条策略才能加载,两条策略如果各自放行不同的域名,交集为空,两边的域名全部死掉。官方文档的措辞是,追加的策略只能进一步收紧被保护资源的能力。
运维那条策略在自己看来完全正确——它列的域名一个不多一个不少,是审计报告要求的那些。前端那条策略在自己看来也完全正确。两条都对,合起来把站点打残了。
控制台指着的是第二条,查配置的人盯着第一条
排查这类问题还有一个额外的绊子:控制台里报出来的策略原文,是拦下这次加载的那一条,也就是后加的那条。而查问题的人第一反应是去看应用里那份自己熟悉的策略——那份策略里域名写得好好的,怎么看都没错。两边对不上,很容易得出“浏览器有bug”的结论。
快速判据只有一条:直接看原始响应头,数一数Content-Security-Policy这个字段名出现了几次。抓包工具和浏览器的网络面板都会把同名字段合并显示,看起来像一条用逗号连起来的长串,这时候要留意逗号——策略内部用分号分隔指令,逗号出现在顶层就意味着这是两条独立的策略被合并展示了。用命令行工具直接看原始响应头最稳妥,这类响应头层面的排查动作,服务器配置那份清单里整理过一批。
顺带一提,接入层加响应头这个动作本身还有个老毛病:很多接入层的响应头指令在有多个配置块的时候不会继承,改了外层不生效、或者内层把外层整个覆盖掉。Nginx那篇拦爬虫的文章里记过同一个坑的另一个版本,判断方法一样:不看配置文件,看真实响应。
现网这些白名单,实际长成什么样?
实验台上的结论要落到现实里,得知道现实是什么样。这一轮抓了149个站的首页,全程只发GET、不登录、不提交任何东西,样本包括DTC品牌站、支付与结账服务商、几个大型零售站,以及一批做对照的非电商站点。
覆盖率与写法分布
| 项目 | 站数 |
|---|---|
| 首页发拦截模式的策略 | 53个(35%) |
| 首页发仅上报模式的策略 | 7个,其中3个只发仅上报不发拦截 |
| 写在页面meta标签里 | 12个 |
| 同一个响应里发了两条策略 | 1个 |
| 策略里写了老的上报指令 | 16个 |
| 策略里写了新的上报指令 | 6个 |
| 两个上报指令都写了 | 6个 |
| 一个上报指令都没写 | 37个 |
最后三行连起来读才有意思。发了策略的53个站里,37个压根没配上报——它们对自己拦掉了什么完全没有可见性。剩下16个配了的里面,有6个用的正是官方推荐的“两个都写”,而这一节前面的实验已经证明这种写法在支持新指令的浏览器上一份报文都收不到。换句话说,53个站里真正能收到违规报告的,是那10个只写了老指令、没跟着文档去升级的。照文档做了升级的那6个站里,有支付服务商,也有跨境收款平台。
清单有多长,又有多少条是活的
把每个站策略里管脚本的那部分拆开数,条目总数1858条,人均35.1条,中位数只有1——分布极度不均,一半以上的站只写了'self'这一项,而排在前面的几个站单站超过100条。最长的一个站有655条,并且它的策略里根本没写管脚本的那条指令,全靠默认指令回退,也就是说这655条同时管着脚本、图片、样式、字体、连接。
把这些条目里的具体主机名去重,得到840个,逐个做三次间隔解析,三次都失败才算死。结果是46个主机名已经解析不出来,分布在11个站。通配符条目一律不参与判定,因为把星号去掉之后的父域本来就不该解析。
死掉的这46条里,几类标本很有代表性:
- 服务已经关停的:某个当年遍地都是的社交分享按钮服务,三个域名还留在两个站的清单里,而那家服务多年前就停了。
- 公司被收购改了名的:一家边缘加速厂商被收购后整体换了域名,某个站的清单里新旧域名都写着,而且两个都解析不出来——改名之后那家公司后来又整个没了。清单经历了一次域名迁移和一次公司消失,一次都没更新。
- 测试环境跑进了生产:一个支付网关的测试端点、一个风控服务的沙箱端点,分别留在两个电商站的生产策略里。
- 根本不是域名的:某个站的清单里有5个32位的小写字母串,是浏览器扩展的标识符。扩展资源该用专门的协议前缀声明,写成裸串之后浏览器只能把它当主机名去解析,自然永远解析不到。这5条从写下的那天起就没有生效过,也从没有人发现。
- 自家的兄弟域名:一个多品牌集团的站点,清单里列着六个兄弟品牌的邮件子域和博客子域,现在全都解析不出来。
写了但没用到的,占了四分之三
判断“这条还有没有用”不能只看首页HTML,因为大量第三方是被标签管理器在运行时动态注入的,光看源码会把它们全算成没用到。所以这一轮用真实浏览器跑首页,收集页面实际发出的每一个请求的域名,再跟清单对照。同时把被反爬拦住、页面根本没渲染起来的站排除掉——判据是浏览器发出的请求域名数少于12个。
剩下15个渲染成功、且清单里有具体条目的站,988条条目里首页真正请求到的只有241条,747条没用到,占75%。单站比例从25%到94%不等。
要说清楚的是,没用到不等于该删——有些条目服务于结账页、账户页、活动页,首页当然不会触发。这个数字真正说明的是另一件事:这份清单绝大部分内容,日常是没有任何验证的。它们对不对、还活着没有、写没写错,只有等到某个用户走到某个页面、组件没出来、有人提工单,才会被检验一次。
十个站的首页,当场就有东西被自己的策略拦掉
真实浏览器跑首页时顺手记了控制台,35个站里有10个的首页当场就有资源被自己的策略拦下。挑几个说:
- 一个珠宝品牌站,被拦的是它自己用的那家CDN厂商的性能统计脚本——清单里没有那个统计域名。统计类脚本是这类事故的高发区,因为它们换域名最勤,处理第三方统计脚本那篇里提到的几家,域名前后换过不止一次。
- 一个协作工具站,被拦的是它的前端错误监控脚本。收集错误的那个脚本本身被拦掉了,这意味着这个站之后发生的所有前端错误都不会有人知道,包括这一次拦截本身。
- 一个英国零售站,被拦的是它自己的站标图片——策略里图片那一项只写了
'self'和一个验证码服务的域名,而站标挂在带www前缀的那个主机名上,跟页面所在的主机名不是同一个,于是'self'不覆盖它。这正是上一节那条规则的现网版本:不带星号的条目只管自己,不管兄弟主机名。 - 一家跨境汇款平台,被拦的是它自己一个子域上的内嵌页面。
- 两个美妆零售站和一个家具站,被拦的是广告平台的转化统计像素——转化数据因此少了一块,而广告后台只会显示转化变少,不会告诉你原因。
最后一条保哥想多说一句。转化像素被拦掉的后果,不是页面出问题,是投放数据变得不可信——归因链路缺了一环,优化师会以为是创意或者受众的问题,然后去调整根本没坏的东西。跟数据分析师对账那份清单里讲过埋点对不上时的排查顺序,这一行响应头值得加进去当第一个怀疑对象,因为它的症状恰好是“数据少了一部分但系统一切正常”。
这份清单要怎么养,才不会反过来咬人?
把上面所有结论收成可以直接排期的动作。顺序按投入产出排,前三条几乎没有成本。
先把眼睛装上
第一件事是让拦截变得可见,在此之前谈治理都是空的。
- 在前端监控里加一条违规事件监听,把被拦地址、生效指令、策略原文一起送回你现有的错误上报接口。一行代码,不需要新基础设施,而且它是唯一能拿到被拦域名的途径。
- 如果非要用响应头里的上报,就只写老指令。在新机制能稳定投递之前,“两个都写”等于没写。要验证很简单:随便找个页面故意加载一个必定被拦的资源,看接收端有没有收到——收不到就说明这条链路是断的,不是没有违规。
- 把上线前的观察期改成主动跑。用无头浏览器把首页、分类页、商品页、购物车、结账页各跑一遍,直接读控制台里的违规信息。这比等上报可靠,也比等用户投诉快。这套东西跑起来不复杂,跟做单因素隔离实验时搭的采集脚本是同一类活儿。
再把清单本身管起来
- 给每一条加一个负责人和一个到期日。这份清单最要命的地方是它没有主人——加的时候是某次接入的顺手动作,之后就永远留在那里。哪怕只是在配置文件里写行注释,标上“谁加的、为哪个组件加的、什么时候复核”,也比一串裸域名强得多。
- 定期跑一次解析检查。把清单里所有不带星号的主机名拿出来解析一遍,解析不出来的要么是服务停了、要么是当初就写错了,两种都该清掉。这个检查一条命令就能跑完,本文那46个死条目就是这么找出来的。
- 把清单和真实流量对一次账。跑一遍主要页面,收集实际请求的域名,跟清单做双向差集:清单里有、实际没请求的是待复核项;实际请求了、清单里没有的是正在被拦或者随时会被拦的高危项。
- 把第三方的变更通知接进来。这条最难,因为对方的变更公告发给的是商务或者对接人,收件人不会意识到这跟一行响应头有关。可行的折中是在接入任何第三方组件时,把“域名变更需要同步”写进对接文档,并且留下一个具体的接收人,而不是一个部门。
最后是路线选择
- 迁移到随机值加动态传递的写法时,要按整体切换来排期,不能当成“加个关键字”。切换的那一刻清单全部失效,所有靠清单放行的第三方组件会同时停摆,必须先把它们改成受信任脚本动态加载,或者接受一次集中改造。
- 接入层加策略之前,先确认应用层有没有在发。数一数原始响应头里这个字段名出现了几次,两条就是交集,不是并集。
- 站点迁协议、换域名、上新CDN的时候,把这行响应头列进检查表。它不在任何一份常规的迁移清单里,而它恰恰是最容易被落下、后果又最隐蔽的一行。顺手把同一层的其他老响应头一起复核掉,比如用X-Frame-Options挡点击劫持那种早年加上、之后再没人看过的配置。
保哥最后想说的是,这类配置有个共同的性格:写错的代价不是立刻报错,而是把某个功能安静地关掉,账单照付、数据照跌、页面照样返回200。能对付这种性格的只有一样东西,就是把“我以为它在工作”换成“我刚验证过它在工作”。
常见问题解答
页面返回200、服务端日志干净,怎么快速确认是不是被策略拦了?
打开浏览器控制台看有没有violates the following Content Security Policy directive这类红字,这是最直接的判据。如果控制台也被清空了或者拿不到用户现场,退一步看响应头:把页面的原始响应头打出来,确认这个字段存在、并且数一数它出现了几次。还有一个反向判据——被策略拦下的请求在浏览器网络面板里会显示为失败,但服务端访问日志里完全没有对应记录,因为请求根本没发出去。这一点可以用来跟真正的网络故障区分开。
违规报告里指着的域名明明在白名单里,为什么还被拦?
最常见的原因是这个地址在中途发生了跳转,真正被拦的是跳转终点,而报告出于隐私考虑只会给出发起地址。规范里明确写了这样做是为了不让页面探测到重定向目标。判断方法是打开控制台看那条错误信息——控制台里说的是终点地址,跟报告字段里的不是同一个。第二种可能是响应里有两条策略,报告出自后面那条。
加了strict-dynamic之后,原来白名单里的第三方为什么全挂了?
这是设计如此。官方文档写明,这个关键字一旦出现在某条指令里,同一条指令里的主机名条目、协议条目、'self' 和 'unsafe-inline' 全部会被忽略。策略从“列举可信域名”整体换成了“标记可信脚本”,两套机制不叠加。要迁移必须整体切换:先让所有第三方脚本改由带随机值的可信脚本动态加载,再启用这个关键字,不能一边加关键字一边指望旧清单继续生效。
白名单里写了根域名,为什么它的子域还是加载不了?
不带星号的条目只匹配它自己那一个主机名,不覆盖任何子域。反过来,带星号的条目只匹配子域,不覆盖根域本身——想两边都放行必须两条都写。另外要注意星号在这里能跨任意层数子域,跟证书里的通配符只能覆盖一层不一样,按证书的习惯来理解会在两个方向上同时判断错。
为什么第三方脚本在测试环境好好的,上生产就被拦?
先查两个最容易被忽略的差异。一是端口:条目里不写端口等于只允许该协议的默认端口,如果生产走了非默认端口就不匹配。二是协议:站点从http迁到https之后,清单里留着的http条目在严格实现的引擎上会判不匹配,而在宽松实现的引擎上照常放行——同一份配置在两个浏览器上能得出两种结果,只在一个浏览器上验证是不够的。第三个常见差异是生产的接入层额外加了一条策略,两条策略取交集。
配了违规上报却一份报文都收不到,是接收端的问题吗?
先加一个阳性锚点再下结论:找个页面故意触发一次必定违规的加载,用老的上报指令投到同一个接收地址,如果这一份能收到,说明接收端、证书、网络路径都没问题。本文实测的情况是,只写老指令能收到、只写新指令收不到,而按官方推荐把两个都写上同样收不到——因为支持新指令的浏览器会忽略老指令。在新机制稳定之前,只写老指令反而是能拿到数据的写法。
白名单里的条目能不能定期自动清理?
解析检查可以自动跑,把清单里所有不带星号的主机名解析一遍,三次都失败的基本可以判定为服务已停或当初写错。但“没被请求到”这一项不能自动删,因为很多条目服务于结账页、账户页这类首页不会触发的场景。建议的做法是自动检查只负责产出待复核清单,删除动作交给知道这条是给哪个组件用的那个人——这也是为什么每条都该记下负责人。
这套东西对SEO有直接影响吗?
有,而且是间接但确凿的两条。第一条是渲染:搜索引擎抓取时同样会执行这行响应头,被拦掉的脚本如果负责渲染主体内容或者结构化数据,抓到的就是残缺页面。第二条是数据:转化像素和统计脚本被拦之后,投放和分析看到的数字会系统性偏低,而后台不会给出任何原因提示,很容易导致对着没坏的东西做优化。
权威参考资料
本文标题:《白名单上明明写着那个域名,浏览器还是把它拦了,而违规报告指着的正是它》
本文链接:https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0