Subresource Integrity 前端资源完整性校验实战:给 CDN 脚本加锁,挡住供应链投毒与篡改

你的网站上一半的代码,其实不是你写的

打开一个普通个人站的页面源码数一数,你会发现真正属于自己的代码可能不到一半。CDN 上加载的 jQuery、Bootstrap、字体库、统计脚本、评论系统、客服插件、广告位——这些第三方资源来自别人的服务器,通过网络送到你的访客浏览器里执行。你相信它们,是因为你当初引用的那一刻它们是安全的。但"当初安全"不等于"永远安全":CDN 可能被入侵、第三方库可能被投毒、域名可能被劫持、你引用的那个 URL 后面的内容随时可能被替换成一段窃取 Cookie、篡改支付信息或偷偷挖矿的脚本。

这类风险有个名字叫供应链攻击。对个人站长来说,它比 SQL 注入更隐蔽,因为攻击者根本不用碰你的服务器——只要污染你引用的那个 CDN 文件,所有加载它的网站就一起中招。这篇文章要讲的 SRI(Subresource Integrity,子资源完整性),就是浏览器提供的一个"防篡改"机制,能让你的页面在加载第三方脚本时先验一次指纹,对不上就不执行。

SRI 的原理:给资源算一个指纹

SRI 的思路非常朴素:你在引入外部脚本或样式时,额外写上一个该文件内容的哈希值。浏览器下载完文件后,自己重新计算一遍哈希,和你在标签里写的比对——一致才执行,不一致就直接拒绝加载,控制台报错。

关键在于,这个哈希是基于文件内容算的,内容改一个字节,哈希就完全不同。所以攻击者即使控制了 CDN,也无法在不改变哈希的情况下替换文件;而他又无法修改你页面里写死的哈希值(因为那在你自己的 HTML 里)。这就形成了一道只有内容一致才能通过的闸门。

浏览器支持情况已经很好了,Chrome、Firefox、Edge、Safari 都支持 SRI。写错或者引用被篡改时,资源会被浏览器直接拦截,这比"静默执行恶意代码"好得多。

怎么写 SRI 属性

最简单的形式是在 <script> 或 <link> 标签里加一个 integrity 属性,值是 算法-哈希 的格式。举个例子:

<script src="https://cdn.example.com/jquery.min.js"
        integrity="sha384-abc123...=="
        crossorigin="anonymous"></script>

<link rel="stylesheet"
      href="https://cdn.example.com/bootstrap.min.css"
      integrity="sha384-def456...=="
      crossorigin="anonymous">

几个必须注意的点:

  • integrity 的值是 sha256-、sha384- 或 sha512- 前缀加上 Base64 编码的哈希。官方推荐用 sha384,兼顾安全性和长度。
  • 加 SRI 的外部资源必须同时加 crossorigin 属性。因为跨域请求要拿到文件内容来做完整性校验,必须走 CORS;如果 CDN 没有正确返回 Access-Control-Allow-Origin,浏览器会因为跨域失败而直接拒绝加载。这一步是新手最常翻车的地方。
  • 可以用空格分隔写多个哈希,浏览器只要匹配其中一个就算通过。这在你想要平滑切换 CDN 域名或兼容多个版本时有用。

怎么算出正确的哈希值

方法有好几种,最推荐的是直接用命令行算,避免复制粘贴出错。假设你要引用的文件在本地或者能下载:

# 方法一:用 openssl 计算 sha384 并转 Base64
curl -s https://cdn.example.com/jquery.min.js \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

# 输出类似:K7gNUnKW0yFtZ0J1QmxzE0S5... ,前面加上 sha384- 前缀即可

# 方法二:一条命令直接生成完整的 integrity 值
url="https://cdn.example.com/jquery.min.js"
hash=$(curl -s "$url" | openssl dgst -sha384 -binary | openssl base64 -A)
echo "sha384-$hash"

很多主流 CDN(比如 jsDelivr、cdnjs)在你复制文件链接时,会直接在页面提供现成的 integrity 值,直接用它最省事。但要注意不要盲信第三方生成的哈希——理想做法是自己下载文件算一遍,和 CDN 提供的比对,两者一致才放心。尤其对于一些不太知名的 CDN,自己算哈希是唯一的验证手段。

个人站最实用的用法:给 CDN 资源上锁

个人站最典型的使用场景就是把公共库放到公共 CDN 上,既省自己的带宽又利用浏览器缓存。但公共 CDN 的安全性参差不齐,2024 年就出过好几起知名 CDN 被投毒、向特定 Referer 返回恶意代码的事件。给这些引用加上 SRI,就能在文件被替换时第一时间发现并阻止执行。

下面是一个完整的例子,把一个第三方库引入到自己页面的正确姿势:

<!-- 正确:integrity + crossorigin 缺一不可 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.21/dist/vue.global.prod.js"
        integrity="sha384-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        crossorigin="anonymous"
        referrerpolicy="no-referrer"></script>

再补一个 referrerpolicy="no-referrer" 是个好习惯,可以避免你的域名被第三方 CDN 从 Referer 头里采集,稍微降低一点信息泄露风险。

搭配 CSP 才能真正形成防线

SRI 和 CSP(内容安全策略)解决的是不同层面的问题,但搭配起来威力更大。SRI 保证"这个文件内容没被改过",CSP 保证"页面只允许加载我信任的域名和方式"。只加 SRI 而不加 CSP,你的页面仍然可能被注入一个全新的、带正确 SRI 的恶意脚本标签;只加 CSP 而不加 SRI,则无法防范信任域名内的文件被替换。

一个适合个人站、又不至于把页面搞崩的 CSP 可以这样写(放在 Nginx 响应头里):

add_header Content-Security-Policy "default-src 'self'; \
  script-src 'self' https://cdn.jsdelivr.net; \
  style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; \
  img-src 'self' data: https:; \
  object-src 'none'; \
  base-uri 'self'; \
  frame-ancestors 'self'" always;

这里 script-src 限制了脚本只能来自你自己的域名和你明确信任的 CDN,object-src 'none' 彻底禁掉 Flash 之类的插件对象,base-uri 'self' 防止攻击者通过注入 <base> 标签劫持所有相对链接,frame-ancestors 'self' 则是防点击劫持的现代替代。注意 always 参数,它保证错误页面(4xx/5xx)也带上这个头。

上线 CSP 有个大坑:一旦写错,页面上的脚本会被静默拦截,站点看起来就是"白屏 + 控制台一堆报错"。所以先用 Content-Security-Policy-Report-Only 跑一段时间,观察会有哪些资源被拦、收集报告,确认无误后再切换成正式生效的头,这是最稳妥的灰度方式。

报告与监控:别让拦截静默发生

SRI 校验失败时,浏览器只会在控制台报错,普通的服务器访问日志是看不到的——因为文件已经下载完了,只是没执行。如果某天你依赖的 CDN 被入侵,你可能毫无察觉,直到有用户反馈"网站功能坏了"。要主动发现问题,可以监听浏览器的 securitypolicyviolation 事件,或者用 CSP 的 report-uri / report-to 指令把违规上报到一个收集端点:

add_header Content-Security-Policy-Report-Only "default-src 'self'; \
  script-src 'self' https://cdn.jsdelivr.net; \
  report-uri /csp-report" always;

然后在 Nginx 或后端加一个 /csp-report 接口接收上报,写入日志。如果你用了 Sentry 这类前端错误监控,也可以直接在 JS 里监听:

document.addEventListener('securitypolicyviolation', function (e) {
  // e.blockedURI 是被拦截的资源地址,e.violatedDirective 是触发的指令
  console.warn('CSP/SRI blocked:', e.blockedURI, e.violatedDirective);
  // navigator.sendBeacon('/csp-report', JSON.stringify({...}));
});

这样当 SRI 真的拦下一个被篡改的脚本时,你能第一时间从日志里看到,而不是等用户来投诉。

四个容易踩的坑

坑一:忘了加 crossorigin 导致资源直接加载失败。这是最常见的低级错误。SRI 校验跨域资源需要 CORS,不加 crossorigin="anonymous" 的话浏览器根本拿不到可以用来校验的内容,结果就是资源被拒。每加一个 integrity,就顺手检查同一个标签有没有 crossorigin。

坑二:CDN 更新文件后旧哈希失效,整个站白屏。SRI 是内容哈希,CDN 上一旦更新库文件(哪怕只是改了注释),哈希就变了,你写死的 integrity 立刻不匹配,资源被拦,站点崩了。所以要么锁定具体版本号(比如 vue@3.4.21 而不是 vue@3 或 vue@latest),要么在 CDN 更新后同步更新 integrity。用固定版本 URL 是个人站必须养成的习惯。

坑三:把 SRI 用在会动态变化的资源上。有些第三方脚本(尤其是统计、广告、A/B 测试类)本身内容就是动态的,或者会加载二级脚本。这类资源不适合加 SRI,因为它的哈希一直变、而且它引入的子资源你也管不到。对于这类你必须信任的第三方,改用 CSP 的域名白名单来控制,而不是 SRI。

坑四:以为加了 SRI 就万无一失。SRI 只保护"这一个文件的内容不被篡改",它不能防止第三方脚本本身就在正常地窃取你的用户数据(毕竟它确实是你合法引入的),也不能防止 DOM 型 XSS。SRI 是纵深防御的一环,不是银弹。它要和 CSP、HTTPS、依赖最小化(能自己托管的库就自己托管,少引一个第三方就少一个风险面)一起用,才是完整的供应链安全策略。

小结:把信任变成可验证

SRI 的价值不在于它多复杂,而在于它把一个模糊的信任问题变成了一个确定的数学问题:内容对得上哈希就执行,对不上就拒绝。对于常年依赖各类 CDN 和第三方脚本的个人站来说,这是投入产出比极高的一项加固——花几分钟给几个关键脚本算哈希、加上两个属性,就能在 CDN 被投毒时保住自己的访客。搭配固定版本号、CSP 白名单和违规上报,你就把前端供应链里最容易出事的那个环节,从"完全靠对方良心"变成了"浏览器替我盯着"。下次再从网上复制一段 CDN 引入代码时,请多花十秒钟想想:这段代码,我能验一验吗?

Last modification:October 6th, 2026 at 08:25 pm

Leave a Comment