一、一张来自陌生域名的脚本,为什么值得管
打开任何一个用第三方统计、客服插件、字体和图床的站点,右键查看源码,八成能看到两三个不是自己域名的 <script src="https://cdn.别人的站.com/x.js">。这类外链脚本是浏览器最信任的东西之一:它跑在你页面的同源上下文里,能读 cookie、能改 DOM、能发起任意请求、能把访客的输入原样发到异地。
对个人站长来说,这件事的风险不在于「对方是坏人」,而在于「你控制不了对方的变更」。同事写的那段代码你要 review,第三方 CDN 上那个文件半夜换了个版本,你不会收到任何通知。CDN 一旦被入侵、被域名劫持、或者干脆被卖掉换了运营方,你的页面就成了别人的执行环境。历史上多次真实事故都是这个路径:第三方统计脚本被篡改,注入跳转或挖矿代码。
子资源完整性(Subresource Integrity,SRI)就是给这种情况准备的一把锁。原理极其简单:在 <script> 或 <link> 标签上加一个 integrity 属性,里面放上这个文件内容的哈希值。浏览器加载完文件后会自己算一遍哈希,对不上就直接拒绝执行。文件被改过任何一个字节,哈希就不一样,代码根本跑不起来。
二、SRI 到底校验了什么,没校验什么
先说清楚它的边界,免得产生虚假的安全感。
它校验的是文件内容的完整性:你拿到的这份 JS 或 CSS,和当初你批准的那一份是否逐字节一致。它不校验来源身份,也就是说它不管文件是从哪个域名下的,只看内容。它不防止 XSS,你自己的页面如果有注入漏洞,SRI 帮不上忙。它也不保护通过 import() 动态加载、或者从 JS 内部再拉取的二级脚本。
这个边界决定了 SRI 的适用场景:它只适合那些「内容稳定、版本明确」的第三方静态资源,而且必须是 HTTPS 加载,跨域还必须带 CORS 头。对于内容每天都在变的广告脚本、A/B 测试脚本,SRI 会变成灾难,因为哈希一天变三次,你要跟着改三次,改漏了页面功能就白屏。
| 资源类型 | 适不适合加 SRI | 原因 |
|---|---|---|
| 固定版本的 jQuery、Vue、Bootstrap | 非常适合 | 版本号写死在 URL 里,内容永不变化 |
| 第三方统计、埋点 SDK | 基本不适合 | 厂商随时热更新,哈希会失效 |
| 字体、图标库 CSS | 适合 | 需要 CORS 头,通常 CDN 都支持 |
| 广告与联盟脚本 | 不适合 | 内容动态、且本身就要跨域改写 DOM |
三、生成哈希:一条命令算三个算法
哈希必须基于服务器实际返回的字节计算,不能基于本地文件,因为 CDN 有时会改内容(比如去掉注释、加 banner)。所以正确的做法是直接对线上 URL 计算。
URL="https://cdn.example.com/lib/jquery-3.6.0.min.js"
for alg in sha256 sha384 sha512; do
h=$(curl -sL "$URL" | openssl dgst -$alg -binary | openssl base64 -A)
echo "$alg-$h"
done用 sha384 就足够了,现代浏览器都支持,哈希串也不会太长。三个算法可以同时写进 integrity 属性,用空格分隔,浏览器会挑它支持的最强的一个来校验。这样做的意义在于向后兼容:老浏览器可能只支持 sha256,新浏览器用 sha384。
<script src="https://cdn.example.com/lib/jquery-3.6.0.min.js"
integrity="sha384-AbCdEf...=="
crossorigin="anonymous"></script>crossorigin="anonymous" 是必填项,不是可选项。SRI 校验需要浏览器能读取跨域响应的内容,如果没有这个属性,请求会以 no-cors 模式发出,响应内容不可读,哈希无法计算,浏览器会直接拒绝加载。这个细节让很多人第一次尝试 SRI 时以「资源加载失败」收场。
四、必须实现:加载失败的兜底与告警
SRI 最危险的一点是它的失败方式是静默的。哈希不匹配时,浏览器只会在控制台打一条错误,页面上什么都不显示。如果你的关键脚本是负责渲染商品列表的,用户看到的就是一片空白,而你在服务器日志里什么都看不到。
所以上 SRI 之前,一定要先建好兜底。做法是给关键脚本加 onerror 回退到本地副本:
<script src="https://cdn.example.com/lib/app.js"
integrity="sha384-XXXX"
crossorigin="anonymous"
onerror="var s=document.createElement('script');s.src='/static/lib/app.local.js';document.head.appendChild(s)"></script>本地副本可以是一份你 review 过的、自己托管的同版本文件。这样即便 CDN 出问题或者哈希对不上,功能也不至于全废。代价是多维护一份文件,但对小站来说这个成本完全值得。
除了兜底,还要有告警。用一段极小的上报代码监听 window.onerror 和资源加载失败事件,一旦发现关键资源加载失败就上报到自己的接口。这类上报本身不能依赖任何第三方脚本,否则在关键脚本挂掉的时候它也不在。
五、放进构建流程,而不是手改 HTML
真正让 SRI 长期有效的不是手工加一遍属性,而是把它固化进发布流程。手改的哈希半年后必然和实际文件对不上。合理的形态是在构建的最后一步自动生成:
import base64, hashlib, re, urllib.request
def sri(url, alg="sha384"):
data = urllib.request.urlopen(url, timeout=20).read()
h = base64.b64encode(hashlib.new(alg, data).digest()).decode()
return f"{alg}-{h}"
html = open("/tmp/index.html", encoding="utf-8").read()
for m in re.finditer(r'integrity="[^"]*"\s+data-src="([^"]+)"', html):
html = html.replace(m.group(0),
f'integrity="{sri(m.group(1))}" data-src="{m.group(1)}"')
open("/tmp/index.html", "w", encoding="utf-8").write(html)思路是:模板里先写一个占位属性,构建脚本按 data-src 去线上拉取,算完哈希再回填。这样每次发布都是当前的真实哈希,不会漂移。注意脚本里用的算法必须和浏览器支持矩阵一致,不要用 md5 或 sha1,这两个已经被现代浏览器明确拒绝。
六、四个真实的坑
坑一:CDN 会改写内容
某些 CDN 默认会做 HTML/CSS 压缩、加注释头、做图片转换。这些改写会让哈希永远对不上。验证方法很简单:curl -sL 拉下来的字节数和你本地源文件的字节数不一致,就说明经过了改写。这种资源要么关掉 CDN 的自动改写,要么放弃 SRI。
坑二:CORS 头缺失
第三方域名如果没有返回 Access-Control-Allow-Origin: *,SRI 一定失败,而且报错信息是 «net::ERR_BLOCKED_BY_RESPONSE» 这类看起来像网络问题的措辞,容易让人排查方向跑偏。上线前先执行一次:
curl -sI "https://cdn.example.com/app.js" | grep -i access-control没有任何输出就是没有 CORS 头,别硬上。
坑三:文件名不变内容变化
如果第三方资源用的是 latest.js 这种会变内容的地址,SRI 会让它在对方更新后立刻失效,站点可能直接挂掉。这种情况下正确的做法是先锁定版本号 3.6.0/jquery.min.js,再上 SRI。版本锁定和 SRI 是配套使用的两件事。
坑四:只加了 script 忘了 link
CSS 同样支持 SRI,而且 CSS 被篡改的风险不比 JS 小,因为 CSS 可以做隐藏覆盖、可以加载任意远程图片、可以通过 url() 把访客信息带出去。所有外链 <link rel="stylesheet"> 也要同样处理。
七、自查:你的站点有多少外链脚本没被锁
上线前跑一次清单式的自查,比事后排查便宜太多。抓取首页和几篇文章页,列出所有第三方资源,标注是否带 integrity:
curl -s "https://www.example.com/" | grep -oE '<(script|link)[^>]+(src|href)="https?://[^"]+"[^>]*>' \
| grep -v "www[.]example[.]com" \
| sed -E 's/.*(src|href)="([^"]+)".*/\2/' \
| sort | uniq -c | sort -rn对清单里每一个都是问自己三个问题:这份资源是我主动引入的吗?它的内容会变吗?它一旦被篡改,最坏影响是什么?三个问题都答得出来、并且答案是「内容稳定、影响可控」的,就该加 SRI。答不出来、或者连是谁引入的都不知道的,应该直接删掉,因为没有任何一个统计数字值得冒这个风险。
八、和内容安全策略搭配
SRI 和 CSP 是互补的,不能互相替代。CSP 的 script-src 可以限制只允许从白名单域名加载脚本,SRI 则保证从白名单加载到的那份文件没被改过。两者一起用,才是「只有指定的域名、只有指定的内容」这条完整链路。
add_header Content-Security-Policy "script-src 'self' https://cdn.example.com; object-src 'none'" always;顺序上建议先上 SRI,再逐步收紧 CSP。因为 CSP 一次配错就是全站白屏,而 SRI 的影响范围只有单个资源。先用 SRI 把外链资源逐个锁住,等你对站点的资源清单心里有数了,再写 CSP 会轻松得多。反过来先上 CSP,经常是先被一堆内联脚本卡住,最后不了了之。
九、小结
SRI 不是万能的安全措施,它是一个非常窄但非常实用的控制点:把「第三方文件被篡改」这类你无法感知的风险,变成一次浏览器层面确定的拒绝执行。要用好它,记住四件事:只给内容固定的资源加、必须带 crossorigin 属性、必须有加载失败兜底和告警、必须由构建脚本自动生成哈希。剩下的工作,是先把站点的外链资源清单理清楚,把不认识的删掉,把认识的锁上。