脚本下载完了,一行都没执行,而拦住它的是三个月前你自己抄下来的那串哈希

脚本下载完了,一行都没执行,而拦住它的是三个月前你自己抄下来的那串哈希
张文保 更新 36 分钟阅读 3,028 阅读
本文目录
  1. 为什么脚本整个下载完了,却一行都没执行?
  2. 下载是真的发生了
  3. 失败姿势和网络故障是同一副面孔
  4. 唯一说人话的地方是控制台
  5. integrity钉住的到底是什么?
  6. 版本号只是文件名的一部分
  7. 差14个字节和差14千字节是一回事
  8. 那些看起来最安全的地址,恰好是最没用的
  9. 第三方凭什么在同一个地址上换掉字节?
  10. 动态拼装的服务,结构上就没法钉
  11. 不动版本号也会换字节的四种日常动作
  12. 同样是写错一个字符,为什么后果能差这么远?
  13. 三档后果,报警级别和实际危害成反比
  14. 分界线是base64的字符集
  15. 多个哈希写在一起时,只有最强的那个算数
  16. 跨源脚本漏了crossorigin会怎样?
  17. 校验需要许可,而许可要单独申请
  18. 同源不需要,模块脚本也不需要
  19. use-credentials是另一个陷阱
  20. 哪些地方写了integrity等于没写?
  21. 规范只给了两个元素
  22. 会被校验的比你想的多
  23. 重定向之后校验的是终点
  24. 被拦下的那份字节去哪了?
  25. 它进了缓存,而且立刻能被别人取出来用
  26. 这件事在什么场景下会咬人
  27. 那些真在用SRI的站,是怎么用的?
  28. 用的人比想象中少得多
  29. 不出事的原因不是管得好
  30. 三个值得记住的现网标本
  31. 怎么让钉哈希和第三方更新不打架?
  32. 第一步:用响应头分堆,别靠猜
  33. 第二步:能自托管的就自托管
  34. 第三步:需要跟着更新的,用同强度多哈希做灰度
  35. 第四步:给它配一套本来没有的监控
  36. 第五步:上线前的六项自查
  37. 常见问题解答
  38. SRI校验失败会影响搜索引擎抓取和排名吗?
  39. 哈希不匹配和跨源许可缺失,在监控里怎么区分?
  40. 能不能在onerror里去掉integrity重新加载一次当作降级?
  41. 第三方文档里给的integrity值,可以直接抄吗?
  42. 给同一个第三方脚本同时配CSP白名单和SRI,是不是更保险?
  43. 老站上历史遗留的integrity属性,要不要清理?
  44. 权威参考资料

摘要:integrity属性记录的是你复制那段引入代码当天、那个地址上的那一份字节。第三方发一个小版本、CDN把范围版本解析到新的补丁号、构建流水线重新压缩一次,字节就换了,而这些动作都不经过你。换掉之后浏览器会把资源完整下载下来再拒绝执行,页面上少一块功能,服务端没有日志,前端监控收到一个空的error事件,SRI自己没有任何上报通道。本文用一个真实HTTPS实验台跑了32组判决矩阵,两个引擎结论完全一致,再对92个能抓到的站做了整体普查,把这套机制的边界、判据和可落地的配法写清楚。

先说结论里最反直觉的那一条:哈希对不上的时候,那个脚本文件不是没下载下来,是下载完整了才被拒绝执行的。

在实验台上,一个被拦下的脚本,它在Resource Timing里的 encodedBodySize 是85,transferSize 是385,跟正常执行那一组的数字口径完全一样,只是最后没跑。带宽花了,往返等了,功能没有。

这件事之所以值得单独写一篇,是因为它跟另一类故障长得太像了。上一次我们拆过白名单里明明写着那个域名却还是被拦的情形,那一次是域名变了;这一次是域名一个字没动、路径一个字没动,变的是那个地址背后的字节。两种故障在浏览器里的表现几乎是同一副面孔,但排查的入口完全不同。

这一类“服务端一切正常、用户那一侧少了一块东西”的故障,这几年在独立站上出现得越来越密。服务器全程返回200、订单也进了库,用户眼前那块区域却始终是空白是一种,照着加固清单加一行响应头把嵌进来的地图和支付按钮一起变成摆设是另一种。它们的共同点是:拦截发生在浏览器里,而你所有的日志都在浏览器外面。

为什么脚本整个下载完了,却一行都没执行?

子资源完整性(Subresource Integrity,业内一般直接叫SRI)的工作方式很朴素:你在标签上写一个哈希,浏览器把资源下载下来,算一遍哈希,对得上就执行,对不上就当作加载失败。

MDN把这个过程写得很直白:浏览器会用指定的函数计算资源内容的哈希,然后跟你写的所有值比对,任何一个对上就加载,否则就拒绝加载这个资源并返回一个网络错误

注意最后半句:返回的是一个网络错误。这就是所有排查困难的源头——一次哈希不匹配,被翻译成了一个跟断网、跟DNS解析失败、跟对方服务器502一模一样的信号交给你的代码。

下载是真的发生了

为了确认字节到底有没有过来,实验台在每一组里都读一次Resource Timing。下面是同一个地址、同一个标签写法,只改服务端返回哪一个构建产物的对照:

组别钉的哈希服务端实际给的encodedBodySize脚本执行了吗
基线构建1构建176
漂移构建1构建285
没写integrity构建285
服务端不发CORS头构建1构建10

第二行和第四行的差别,是这张表里最值钱的一格。哈希不匹配那一组,字节数是完整的85;跨源许可缺失那一组,字节数是0。同样是脚本没跑起来、控制台都在飘红,Resource Timing里的字节数能直接告诉你是哪一道关卡拦的:数字是完整的,说明内容已经交付、卡在了校验;数字是0,说明请求在跨源许可那一层就没能把内容交给页面。

这个判据在真实排查里很好用,因为它不依赖你能不能看到控制台。线上环境里用户不会给你截图控制台,但性能监控里的资源条目是采得到的。

失败姿势和网络故障是同一副面孔

把一次哈希不匹配能产生的所有信号都收集一遍,得到的是这么一张清单:

  • 脚本标签的 onerror 触发了,但事件对象上只有 isTrusted 一个自有属性,message 是空字符串。
  • window.onerror 完全不触发,因为它只管脚本执行期间抛出的异常,不管脚本压根没执行这件事。
  • fetch()integrity 选项去拉,抛的是 TypeError: Failed to fetch,跟对方服务器宕机时抛的是同一个错误名。
  • securitypolicyviolation 事件一次都没有触发。SRI不走内容安全策略那套上报,它自己也没有一套。

最后一条得再强调一遍。内容安全策略至少还有 report-uri 这条老通路能把违规报文送出去,SRI连这个都没有。整个规范里没有任何一个字段是用来告诉服务端有一次校验失败的,所以线上到底有多少用户在多少次访问里少加载了那个脚本,这个数字不存在,也没有办法存在。

这一点对习惯了从服务端找答案的人特别不友好。访问日志能还原出爬虫的抓取轨迹、能识别伪造的抓取来源,但它对这次故障一无所知——从服务端看,那个脚本是被完整送出去的,状态码200,字节数正常,一切都很成功。

唯一说人话的地方是控制台

好消息只有一个:控制台不但告诉你被拦了,还把当前那份内容的哈希直接算好打给你。两个引擎的措辞不同,信息量一样:

Failed to find a valid digest in the 'integrity' attribute for resource '……' with computed SHA-384 integrity 'Ll9K58dtW4n1Ej7kISIyIr…'. The resource has been blocked.

None of the “sha384” hashes in the integrity attribute match the content of the subresource at “……”. The computed hash is “Ll9K58dtW4n1Ej7kISIyIr…”.

那串computed后面的值,就是这个地址现在返回的内容的哈希。换句话说,浏览器在报错的同时,已经把修复这次故障需要的那个新哈希写在屏幕上了,复制粘贴就能用。这是整套机制里唯一一处对人友好的设计,可惜它只出现在控制台里,只有主动去看的人才看得见。

integrity钉住的到底是什么?

很多人第一次配SRI的时候,心里的模型是把某个库锁在某个版本上。这个模型不对,而且不对得很关键。

integrity钉住的既不是版本号,也不是地址,而是那个地址在你复制它的那一刻返回的那一串字节。字节多一个空格、少一行注释、压缩器换了个换行策略,哈希就是彻底不同的另一个值——哈希函数没有近似这一说,改一个字符和改一整个文件,在结果上是同等的陌生。

版本号只是文件名的一部分

这里有个很容易糊过去的地方:URL里那段版本号,对浏览器来说没有任何特殊含义,它就是路径里的几个字符。是CDN在按照自己的规则决定这段字符对应哪一份文件,而不同的写法对应的是完全不同的承诺。

拿最常见的几种引入写法实测一下,同一个库、同一个文件名,只改URL里版本那一段:

URL里的版本写法实际给的版本字节数Cache-Control
bootstrap@5.3.35.3.380721max-age=31536000, immutable
bootstrap@5.35.3.880496max-age=604800, s-maxage=43200
bootstrap@55.3.880496max-age=604800, s-maxage=43200
不写版本5.3.880496max-age=604800, s-maxage=43200

把最后一列读一遍,答案已经写在响应头里了。钉死到补丁号的那个地址,CDN给的缓存策略是一年加 immutable,意思是这份内容承诺永不改变;只写到主版本或者次版本的那些地址,缓存策略掉到7天、边缘12小时,意思是这份内容随时会变。

这就是判据本身。你不需要去猜某个第三方会不会发新版,也不需要去读它的发布节奏:CDN已经用缓存时长给每一个地址标注了会不会变,而钉哈希的人从来不看那一栏。一年加immutable的地址可以钉,7天的地址钉上去就是在等它挂。

缓存时长这一栏平时是拿来调回源率和边缘命中的,很少有人把它当成一份内容变更承诺书来读。但它确实是——响应头本来就是服务端对这份内容作出的一系列声明,缓存策略只是其中说得最直白的一条。

差14个字节和差14千字节是一回事

再看一组更容易骗过直觉的数据。同一个前端框架的生产包,钉死到补丁号的地址返回10737字节,只写到主版本的地址返回10751字节。差14个字节,肉眼看diff大概就是几处版本字符串和一两行边界处理。

但这两份内容的sha384是两个毫不相干的值。在哈希这里没有差不多这个概念,14个字节的差别和18千字节的差别(另一个框架的两种写法之间正好差这么多)产生的后果完全一样:脚本不执行。

顺带一提,同一批测试里有一个地址直接返回了404——那家CDN对省略版本号的写法有自己的一套路径解析规则,跟另一家不一样。所以别指望不同CDN之间的写法可以照抄。

那些看起来最安全的地址,恰好是最没用的

把上面两件事合起来,会得到一个有点尴尬的推论。

只有内容不会变的地址才适合钉哈希,而内容不会变的地址包括两类:钉死到补丁号的开源库文件,以及文件名里带内容指纹的自家打包产物。这两类文件本来就不会在你不知情的时候变化,钉上哈希之后,真正被防住的只剩一种情况——有人入侵了那个CDN,把那个具体文件的内容换掉了。

这个威胁是真实存在的,SRI在这里确实有用。但它同时意味着:凡是你需要跟着第三方一起更新的脚本,也就是分析、客服、支付、标签管理这一整类,SRI都用不上,因为它们的正常形态就是同一个地址随时换内容。这个矛盾不是配置技巧能绕过去的,它写在机制里。

第三方凭什么在同一个地址上换掉字节?

站在自己这一侧看,第三方在同一个地址上偷偷换内容像是一种不负责任。站在对方那一侧看,这恰恰是他们卖的东西。

动态拼装的服务,结构上就没法钉

最典型的例子是按浏览器特性动态拼装补丁包的那类服务。Mozilla的技术博客当年介绍这种做法时说得很清楚,服务端会分析浏览器的user-agent头以及请求的特性列表,然后构建出这个浏览器所需要的补丁清单

这句话翻译成SRI的语言就是:同一个地址,一百个访客拿到的是一百份不同的字节。你在自己电脑上算出来的那个哈希,只对你这个浏览器的这个版本成立,换一个用户就不成立。这类服务不是不想支持SRI,是它跟SRI的前提直接冲突。

而这类服务后来出了什么事,做外贸独立站的同行大概都还有印象。安全团队Sansec在2024年6月的报告里写道,这个域名被一家公司收购之后开始向移动设备注入恶意代码,而有十万以上的站点在用它

报告里还有一段更值得读的描述:那段代码有专门的反调试保护,只在特定的移动设备上、在特定的时段激活,检测到管理员身份就不激活,发现页面上装了网页分析服务就延迟执行。换句话说,它被设计成让站点自己的人最不容易碰到。

把这两件事并排放:SRI存在的全部理由就是防这种事,而这次事件发生的那个地址,结构上根本没法配SRI。这大概是整套机制最难堪的一个巧合。

不动版本号也会换字节的四种日常动作

抛开极端案例,第三方在你不知情的情况下换掉字节,靠的是一些完全正常的工程动作:

  • 发一个补丁版本,而你引的是范围版本地址,CDN自动解析到新的补丁号。
  • 换一次压缩工具链或者升级一次构建器,代码逻辑一行没改,产物字节全变。
  • 把服务迁到新的边缘节点或者新的对象存储,中间加一层自动优化,比如把注释再删一遍。
  • 做灰度,同一个地址在不同区域返回不同版本,你在办公室算的哈希和用户拿到的内容不是一份。

这四件事有一个共同点:它们全都不会触发第三方给你发通知,因为在对方的流程里,这些根本不算变更。版本号没动,接口没动,文档没动,只是重新构建了一次而已。

做过一段时间独立站的人对这种沉默应该不陌生。第三方统计脚本悄悄换掉自己那个图标的样式、图标库把某个字形的SVG路径重画一遍,都属于同一类:对方按自己的节奏迭代,你这边只有等到用户反馈才知道。区别只在于,以前这类变化最多让页面丑一点,现在会让整个脚本消失。

同样是写错一个字符,为什么后果能差这么远?

把integrity的各种写错方式挨个试一遍,会发现结果分成两极,而且分界线在一个谁都想不到的地方。

三档后果,报警级别和实际危害成反比

写法控制台资源实际后果
算法名写成md5- 或sha1-报错或警告照常执行防护静默取消
算法名大写成SHA384-报错照常执行防护静默取消
哈希值不是合法base64报错照常执行防护静默取消
integrity写成空字符串没有输出照常执行防护静默取消
把哈希写成了十六进制报错被拦脚本当场死掉
正确的哈希前后多了空格没有输出照常执行正常
多个哈希用换行分隔没有输出照常执行正常

前四行是同一类:浏览器解析不出任何一条有效的哈希元数据,于是整个integrity属性被当作不存在,资源照常执行。规范里这句话写得非常干脆:如果解析出来的元数据是空集合,就直接返回真。

W3C的规范文本里,跳过不认识的算法用的也是同一个逻辑:如果算法不是一个有效的SRI哈希算法标记,就继续处理下一条。所有条目都被跳过之后,剩下的就是空集合,然后返回真。

两个引擎在这里的报警级别还不一样,一个把它记成错误,另一个记成警告,措辞是“integrity属性里没有包含任何有效的元数据”。但无论哪个级别,行为都是放行——一条你以为已经生效的防护,实际上从写下那天起就没生效过,而唯一提醒你的地方是控制台里一行不会导致任何后果的红字。

分界线是base64的字符集

第五行是另一极。把sha384的摘要写成十六进制而不是base64,这个错误的性质跟前面几行完全一样——都是手滑,都是格式不对——但后果是脚本当场被拦,页面上少一块功能。

差别在哪?十六进制字符串正好落在base64的合法字符集里,于是它被当成一条格式有效、只是对不上的哈希;而带感叹号的乱码不在字符集里,就被当成无效元数据丢掉了。同样是打错字,字符恰好合法就打死站点,字符不合法反而静默放行。

这条判据听起来像脑筋急转弯,但在真实的手工维护场景里会碰到:有人从命令行工具里复制摘要,有的工具默认输出十六进制,有的默认输出base64。编辑器往文件头上偷偷塞几个字节就能让整页白屏是同一类问题的老版本,这次换成了摘要格式。

顺带说一句,图标库是这类手工维护的重灾区。图标库跨大版本时语法和引入方式都会变,官方文档里那段带哈希的引入代码也跟着换,而站上那段代码往往是三年前某个人复制进去之后再没人碰过的。

多个哈希写在一起时,只有最强的那个算数

SRI允许在一个属性里写多个哈希,但它们之间的关系不是你想的那样。MDN写得很明确:不同的哈希函数强度不同,从弱到强是sha256、sha384、sha512,浏览器会先挑出用最强那个函数生成的一组,只用这一组来比对,其他的全部忽略

实验台上跑的两组单变量把这句话的后果摆出来了:

  • 写了一条sha256(跟服务端给的内容对得上)加一条sha384(对不上):被拦。因为只有sha384那条算数,而它不匹配。
  • 写了一条sha256(对不上)加一条sha384(对得上):放行。弱算法那条写错了完全没有影响。
  • 写了两条sha256,其中一条对得上:放行。同强度之间是任一匹配即可。

把这三条并起来看:给一个原本工作正常的sha256配置补一条更强的sha384,如果那条sha384是从另一个版本算出来的,你不是加强了防护,而是把原来那条直接作废掉了。升级动作的意图是变严,实际效果是把判定权整个交给了新写的那一条。

保哥见过一次类似的现场,起因是安全评审提了一句“建议升级到更强的哈希算法”,执行的人从新版本的文档里复制了sha384,又保留了老的sha256,上线之后那个组件就消失了。加一条比删一条更容易通过评审,但在这里加一条的破坏力比删一条更大。

跨源脚本漏了crossorigin会怎样?

这是SRI配置里最常踩的一脚,而且它踩下去的时候哈希是完全正确的。

校验需要许可,而许可要单独申请

浏览器要算一个跨源资源的哈希,前提是它有权读到这个资源的完整内容。默认情况下它没有这个权限——跨源脚本可以执行,但内容对页面是不透明的。

W3C规范把这层关系说得很不客气:子资源完整性需要CORS,不带CORS就想用它是一个逻辑错误。MDN那边给的是操作层面的说法:用了SRI的跨源请求必须走跨源资源共享协议,服务器要明确用 Access-Control-Allow-Origin 响应头表示允许,同时你必须在标签上加 crossorigin 属性。

漏了会怎样?实验台上,哈希完全正确、服务端也发了许可头,只是标签上少写了 crossorigin

Subresource Integrity: The resource '……' has an integrity attribute, but the resource requires the request to be CORS enabled to check the integrity, and it is not. The resource has been blocked because the integrity cannot be enforced.

另一个引擎说得更短:这个地址不符合完整性校验的条件,因为它既不是跨源许可的,也不是同源的。

结果是资源被拦——不是跳过校验放行,而是因为没法校验所以拒绝。这个选择在安全上是对的,在体验上很致命:你写了一个正确的哈希,忘了写一个属性,得到的结果和写了一个错误的哈希完全一样。

同源不需要,模块脚本也不需要

同一组实验换成同源资源,不写 crossorigin 照样通过。这符合直觉——同源本来就能读到内容。

但下面这组单变量就不那么符合直觉了。同一个跨源地址、同一个正确的哈希、同样不写 crossorigin,唯一的差别是标签上多了一个 type="module"

标签写法crossorigin哈希结果
普通脚本没写正确被拦
模块脚本没写正确正常执行
模块脚本没写过期被拦

第三行是必须跑的对照组,它证明模块脚本那一组不是跳过了校验,而是真的校验通过了。

原因在MDN的脚本元素页上:跟传统脚本不同,模块脚本在跨源获取时必须使用CORS协议。既然模块脚本本来就走跨源许可,crossorigin 属性对它就是多余的。

于是同一个属性在两种脚本上的必要性完全相反:普通脚本漏写就整个消失,模块脚本写不写都行。而这两种脚本在源码里的差别只有 type 那一个词,代码评审时几乎不会有人注意到属性的必要性跟着变了。

这也是前端和运营之间那几个协作动作点里最容易漏掉的一类:改动本身完全合理,评审也挑不出毛病,只是有一条隐含约束跟着变了,而这条约束不在任何一份检查清单上。

这不是纸上推演。普查里有一个技术媒体站现网就是这么写的:一个模块脚本,带integrity,不带 crossorigin,控制台干干净净。照着它的写法去配一个普通脚本,那个脚本会立刻消失。

use-credentials是另一个陷阱

还有一种写法值得单独拎出来:把 crossorigin 写成 use-credentials。听上去比 anonymous 更完整、更“带上身份”,实际上它跟绝大多数公共CDN是天生不兼容的。

实验台上这一组的报错来自跨源规则本身,跟SRI无关:当请求的凭据模式是include时,响应里的 Access-Control-Allow-Origin 不能是通配符。而公共CDN为了服务所有人,发的恰恰就是通配符。

结果是哈希对得上、属性也写了,脚本照样被拦,而报错信息里一个字都不会提到integrity。排查的人盯着哈希看半天,问题在旁边那个属性的取值上。

哪些地方写了integrity等于没写?

SRI的适用范围比大多数人以为的窄。写在范围之外的地方,浏览器不报错、不警告、不拦截,就是当作没看见。

规范只给了两个元素

W3C规范的原话是:integrity属性被加进了link元素和script元素的内容属性列表。MDN说得更细一点,link元素还要求 rel 是stylesheet、preload或者modulepreload这三种之一。

其余全部无效。实验台上验证了两种最容易写错的:

  • 在图片标签上写integrity,服务端返回一份跟哈希对不上的图片:图片正常显示。而且用脚本去读这个元素,会发现 integrity 根本不在它的属性接口里,说明这个属性对它连存在都算不上。
  • 在内联脚本上写integrity,哈希是从另一个文件算出来的:脚本照常执行。MDN的脚本元素页写得很直接,这个属性在没有 src 的时候不允许出现——规范说不允许,浏览器的处理是当没看见。

普查里正好抓到了一个现网标本:一家支付服务商的首页上,有一个内联的样式元素带着integrity属性。那多半是构建工具统一加的,从上线第一天起就没有起过任何作用,也没有任何机制会告诉他们这一行是白写的。

会被校验的比你想的多

反过来,在支持范围内的地方,校验是一视同仁的。实验台把能加载脚本的写法挨个试了一遍,asyncdefer、动态创建插入、模块脚本、样式表、预加载,全部照常校验、照常拦截。

预加载那一组尤其值得注意:页面上先用 rel="preload" 预取一份,再用普通脚本标签引同一个地址,只在预加载那一处写了integrity。结果是两处一起失败——预取那一份没通过校验,后面的脚本也就没有可用的内容。预加载不是一个可以偷偷跳过校验的旁路,它是同一条管线上的前半段。

重定向之后校验的是终点

还有一种情况在第三方迁移期间很常见:老地址302到新地址。这时候浏览器校验的是重定向终点返回的字节,跟中间跳了几次无关。

控制台在这里比内容安全策略那次厚道一些:它会分别打出发起地址和最终地址两条记录,你能看出来是跳转之后的内容对不上。而在那次里,违规报告给出的地址是跳转前的那一个,看报告的人根本不知道中间还跳过一次。

被拦下的那份字节去哪了?

到这里为止,故事还算符合直觉:内容不对,浏览器拒绝执行。但下面这组实验的结果,会让“拒绝”这两个字的含义变得可疑。

它进了缓存,而且立刻能被别人取出来用

实验台上摆了这么一个页面:同一个地址先后引两次,第一个标签带一个过期的哈希,第二个标签什么都不带,服务端给这个地址配了正常的缓存头。

结果是:第一个标签被拦,控制台照常报错;第二个标签正常执行;而服务端的请求计数是1。

也就是说,那份没通过校验的内容不但下载完了,还完完整整地进了HTTP缓存,紧接着被同一个页面上一个不设防的引用取出来跑了起来。两个引擎都是这个结果,一个是网络层只发一次请求,另一个是浏览器发了三次但服务端只收到一次,剩下两次全走缓存。

保哥第一次看到这个计数的时候以为是实验台的计数器写错了,把服务端日志翻出来数了一遍才认下来:请求确实只有一次,被拦的那一次和执行成功的那一次,用的是同一份下载。

SRI拦的是执行这一步,不是下载,不是缓存。换句话说,它是一道贴在门口的告示,不是一把锁——内容已经进屋了,只是这个门牌上写着别执行。

这件事在什么场景下会咬人

听上去像个只在实验室成立的边角料,但它对应的是一个很常见的现实:同一个库被页面上多个地方引用,而只有一部分引用写了integrity。

比如主模板里的引用是前端同学配好SRI的,某个营销落地页的组件是另一个人加的,图省事没写。那么在这个落地页上,SRI的防护等级就是“没有”——不是打了折,是完全没有,因为那份内容只要被拉下来一次,不设防的那个引用就能用。

顺便还测了另一种写法:同一个地址在页面上出现两次,两次写了不同的哈希。这时候浏览器不会只下载一次然后比对两遍,网络层看到的是两次以上的请求。不同的integrity值会把同一个地址切成互不复用的两份缓存条目,这对性能是笔额外开销,不过跟功能相比算小事。

那些真在用SRI的站,是怎么用的?

机制讲完了,来看现网。这一轮扫了164个域名的首页,只发GET、不登录、不做任何交互,能正常拿到页面内容的有92个,其余的要么被反爬挡住,要么直接超时。

用的人比想象中少得多

92个能抓到的站里,首页上出现integrity属性的只有14个,占15%。作为对照,同一批站里发内容安全策略的有三分之一以上。

这14个站一共钉了44个标签。把它们逐个下载回来重算哈希,结果是42个完全匹配,2个对不上——而那2个来自同一个站,那个站在真实浏览器里会先弹反爬挑战页,压根加载不到正文,所以那2个对不上没法在浏览器里复核,按严口径不计入结论

严格地说,可复核的部分全部匹配。这个结果和写这篇之前的预期是反的:本来以为会挖出一堆过期的哈希,实际上现网在用的那些基本都是对的。

这里必须把口径交代清楚,否则这个数字没有意义。任何一份外部抓取来的数据,先要问的都是这个样本到底代表了什么:这一轮只看首页、只看服务端直接吐出来的HTML、不执行脚本、不登录、不下单。所以结账流程里那些动态插进去的第三方脚本,本轮一个都没采到——如果有人在那些地方配了SRI,本文的数字看不见它。

不出事的原因不是管得好

把这44个地址的形态摊开看,答案就在里面了:

地址形态典型例子内容会变吗
钉死到补丁号的开源库bootstrap@5.3.3、jquery-3.7.1、jquery-validate/1.19.1不会
文件名带内容指纹的自家产物app-22346079ca679d71bdf1.js不会,一变就换文件名
版本串塞进路径的第三方探针beacon.min.js/v4513226cdae…不会,换版本就换地址
建站平台自动产出的资源各家托管平台的构建产物不会,平台管着

四类全是内容不会变的地址。现网SRI之所以没出事,不是因为大家在勤快地更新哈希,而是因为凡是需要更新哈希的地方,大家压根就没配。

这个推论有一个很硬的支撑:44个被钉的标签里,没有任何一个是滚动更新的分析、客服、标签管理或者广告脚本。唯一沾边的那个是某家CDN的性能探针,而它把版本串写进了路径——换句话说,那家CDN为了让自己的脚本能被钉哈希,专门把地址设计成了每个版本一个新地址。

三个值得记住的现网标本

普查里还捡到三个标本,每一个都对应前面讲过的一条机制:

  • 某支付服务商首页上有一个内联样式元素带着integrity属性。按规范这个属性在这里不该出现,浏览器的处理是忽略,所以它从上线起就是一行装饰。
  • 某技术媒体站用模块脚本引第三方,带integrity、不带 crossorigin,一切正常——这正是模块脚本天生走跨源许可的现网例证。
  • 同一个支付服务商的六个自家脚本,文件名本身就是内容指纹,再叠一层integrity。这是整份普查里最健康的一种配法:内容一变文件名就变,哈希永远不可能对不上,SRI在这里只承担防篡改,不承担版本管理。

怎么让钉哈希和第三方更新不打架?

把前面所有实验的结论收成一套可以照着做的东西。核心思路只有一句:先按地址会不会变把资源分成两堆,两堆用两套办法,别指望一个属性通吃。

保哥给客户做前端加固评审时,这一步是放在最前面的:先把页面上所有外部资源列出来,逐个判断它的地址会不会变,判断完再谈配不配SRI。跳过这一步直接给所有第三方脚本加哈希,上线当天可能没事,一个月后开始零零散散地挂,而那时候没人会把它跟一个月前的那次加固联系起来。

第一步:用响应头分堆,别靠猜

上线前对每一个准备钉哈希的地址发一次请求,只看两个东西:

  • Cache-Control 里有没有 immutablemax-age 是不是以年计。是,说明对方承诺这份内容不变,可以钉。
  • 如果用的是公共CDN,看它有没有回一个标注当前解析版本的响应头。有些CDN会直接告诉你这个地址现在指向哪个具体版本,这比你去读它的文档快得多。

max-age 只有几天的地址,不要钉。这不是保守,是尊重对方在响应头里已经写明的承诺——人家说了这份内容会变,你偏要按不变来配,挂了不能怪对方。

如果站点前面还挂了自己的一层CDN,这一步要在回源那一端做,别在边缘缓存过的副本上判断。边缘节点会按自己的规则改写缓存头,你在浏览器里看到的那个 max-age 未必是第三方原本的那个。

第二步:能自托管的就自托管

对于那些确实需要固定版本的库文件,比自己维护哈希更省心的做法是把文件拉到自己域下,走构建流水线,让文件名带上内容指纹。

这么做之后有三个附带好处:同源资源不需要 crossorigin;内容一变文件名就变,哈希永远不会过期;顺带还省掉一次跨域连接,对核心网页指标有实打实的帮助。给静态资源加内容指纹而不是加查询串版本号本来就是缓存策略里的常规动作,SRI只是又给了一个理由。

第三步:需要跟着更新的,用同强度多哈希做灰度

如果某个第三方确实会更新、你又确实想钉,可以利用同强度多哈希任一匹配这个特性:在对方发版前把新旧两个版本的sha384都写进去,等新版全量之后再把旧的删掉。MDN明确提到这个属性支持多个值就是为了让开发者提供资源的替代版本,同时仍然校验它们的完整性

三条纪律:

  • 两个哈希必须同强度。混不同强度只有最强的那一组算数,弱的那条形同虚设。
  • 切换窗口要覆盖对方的灰度周期,不是发版当天就能收。
  • 这套流程要求你能提前拿到新版本的字节,也就是说只对那些愿意提前告知的第三方成立。做不到,就回到第二步自托管。

第四步:给它配一套本来没有的监控

SRI自己没有上报通道,所以监控得自己搭。三个可行的口子:

  • 在页面上给关键的第三方脚本挂 onerror,进错误上报。事件对象里没有原因,但至少知道哪个地址在什么时候失败了,配合既有的告警体系能把静默故障变成有声故障。
  • 加一个探活脚本,定期下载所有被钉的地址、重算哈希、跟仓库里的值比对。这件事的成本很低——本文的普查脚本核心逻辑就二十来行——但它是唯一能在用户之前发现问题的办法。
  • 在业务侧盯那个脚本负责的指标。客服组件的会话发起数、支付按钮的点击率、埋点的上报量,这些数字掉下去的时候,往往比任何技术告警都早。

第五步:上线前的六项自查

最后是一份可以贴在评审清单里的东西:

检查项怎么查写错的后果
跨源资源写了crossorigin没有看标签属性,模块脚本除外资源直接被拦
服务端发了CORS许可头没有看响应头资源直接被拦
算法名是不是三个合法值之一看前缀,注意大小写防护静默失效
摘要是base64不是十六进制看长度和字符资源直接被拦
多个哈希是不是同强度看前缀是否一致只有最强那组算数
写在script或link上没有看元素名和rel取值整个属性被忽略

这六项里有四项在控制台会有输出,两项完全没有。而恰恰是没有输出的那两项——算法名写错和写错元素——对应的是防护静默失效,也就是你以为有、其实没有的那种状态。

常见问题解答

SRI校验失败会影响搜索引擎抓取和排名吗?

直接的排名影响没有,搜索引擎不会因为一个脚本没执行就降权。但间接影响是存在的:如果被拦的是负责渲染内容的脚本,抓取端拿到的就是一个缺内容的页面,这跟页面靠JS渲染而爬虫没跑起来是同一类后果,对那些根本不执行脚本的AI爬虫影响更直接。另外脚本被拦会把完整的字节下载一遍再丢掉,这笔带宽和等待在性能账上是实打实的支出。判断方法很简单:用抓取工具渲染一次,看渲染后的HTML里该有的内容在不在。

哈希不匹配和跨源许可缺失,在监控里怎么区分?

看Resource Timing里那条资源的 encodedBodySize。哈希不匹配时内容已经完整交付,这个数字是正常的文件大小;跨源许可缺失时内容根本没交给页面,这个数字是0。两种情况下 onerror 事件长得一模一样,字节数是目前最省事的分辨办法。控制台的措辞也不同,一个说找不到有效的摘要,一个说被跨源策略拦截。

能不能在onerror里去掉integrity重新加载一次当作降级?

技术上可以,但这等于宣布这道防护随时可以被绕过——而且绕过它的正是你自己写的代码。真要做,也只应该用在功能确实不能缺、且已经确认过风险的场景,同时必须把这次降级上报出去,别让它变成一个永远不会有人发现的静默兜底。更稳妥的顺序是先修哈希,降级只作为限时的应急开关。

第三方文档里给的integrity值,可以直接抄吗?

可以抄,但要抄完之后自己验一遍:把文档里那个地址下载下来,重算一遍摘要,跟文档给的值比对。文档更新滞后于发版是常态,尤其是那些把版本号写成范围形式的引入示例——文档里的哈希对应的是写文档那天的补丁版本,而那个地址今天给的可能已经是另一个。

给同一个第三方脚本同时配CSP白名单和SRI,是不是更保险?

两者管的不是一件事,可以叠加,但要清楚各自的边界。内容安全策略的白名单管的是这个脚本能不能从这个域名加载,SRI管的是加载下来的内容是不是你认识的那一份。前者挡不住已经在白名单里的域名换了坏内容,后者挡不住页面从一个全新的域名加载脚本。真正需要注意的是两者叠加之后的排查成本:一个资源没加载,现在有两个可能的原因,而它们在代码层面的表现完全相同。

老站上历史遗留的integrity属性,要不要清理?

先跑一遍巡检,把所有带integrity的地址重算一遍哈希,然后分三类处理:对得上且地址不会变的,留着;对不上的,说明这个资源现在就是被拦着的,先确认业务有没有受影响再决定是更新哈希还是删属性;算法名写错、写在图片或内联元素上的,这些从来没生效过,删掉即可——留着只会让下一个人误以为这里有防护。

权威参考资料

分享到
标签
版权声明

本文标题:《脚本下载完了,一行都没执行,而拦住它的是三个月前你自己抄下来的那串哈希》

本文链接:https://zhangwenbao.com/subresource-integrity-third-party-script-update-breakage.html

版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交