SRI 子资源完整性防御实战:CDN 脚本篡改拦截配置

SRI 子资源完整性防御(Subresource Integrity)解决的是一个长期被忽略的问题:站点自身代码没有漏洞,但加载的第三方脚本被篡改后,浏览器依然会无条件执行。本文给出一套从哈希生成、属性注入、CSP 强制到篡改验证的完整配置流程,全部命令可直接复制执行。

适用场景

  • 引用第三方 CDN 的静态资源:jQuery、Bootstrap、字体库、统计脚本直接走公共 CDN,源站无法控制远端文件内容;
  • 多团队协作的前端项目:构建产物由 CI 上传到对象存储,发布环节被劫持或存储桶被写入后,前端无法察觉;
  • 合规与等保整改:要求对「外部资源引入」做完整性校验,防止供应链投毒;
  • 安装了浏览器扩展或中间盒的网络环境:运营商劫持、企业代理替换 JS 的案例仍然存在。

需要明确的是,SRI 只解决「内容被改」这一类风险,不解决「资源不可达」或「域名被抢注」问题,这两类需要配合 CSP 与域名监控使用。

前置条件

  • 站点已全量 HTTPS。SRI 在 HTTP 页面上不生效,浏览器会直接忽略 integrity 属性;
  • 需要 Node.js 或 OpenSSL 环境用于生成哈希,Node 18 以上或 openssl 1.1.1 以上均可;
  • 对构建流程有控制权,能修改 HTML 模板或构建脚本;
  • 若使用 CDN,需确认 CDN 开启的是「透传」模式。部分 CDN 的「自动压缩」「图片优化」会重写 CSS/JS 内容,导致哈希失效;
  • 已启用 CSP 的站点需注意 require-sri-for 指令的浏览器兼容性。

原理说明

integrity 属性校验的是什么

浏览器在下载完资源后,对响应体的原始字节做一次摘要运算,再与 integrity 属性中声明的哈希比对。只有完全一致才会执行;不一致则拒绝执行并在控制台输出错误,同时不向页面暴露该资源内容。

<script src="https://cdn.example.com/lib.js"
        integrity="sha384-<base64 摘要>"
        crossorigin="anonymous"></script>

三个关键约束:

  • 哈希算法:支持 sha256 / sha384 / sha512,推荐 sha384,兼顾强度与字符串长度;
  • 响应体一致性:任何字节变化(包括 gzip 后解码异常、BOM 增删、CDN 注入注释)都会导致校验失败;
  • 跨域属性:加载跨域资源时必须同时声明 crossorigin="anonymous",否则浏览器不会返回可校验的响应体,SRI 校验直接失败。

为什么 SRI 能挡住 CDN 投毒

传统供应链攻击的常见路径是:攻击者向上游 npm 包或 CDN 源站写入恶意代码,所有引用该文件的站点被动执行。SRI 把「信任」从「域名可信」收紧为「内容可信」,攻击者即使拿到了 CDN 的写入权限,只要无法同时改掉站点 HTML 中的哈希值,恶意代码就不会被执行。这条链路把攻击成本从「污染一个 CDN」提升到「同时污染 CDN 与每个站点」。

SRI 挡不住什么

  • 攻击者能改 HTML 本身(例如通过模板注入或后台权限),可以直接替换 integrity 值;
  • 资源 URL 被整体替换为新域名,此时 integrity 也会一起改;
  • 动态加载、evalnew Function 执行的代码不受 integrity 约束。

因此 SRI 的定位是「纵深防御中的一层」,需与 CSP、模板输出编码、后台权限收敛配合。

操作步骤

步骤一:生成资源哈希

# 方式 A:OpenSSL 直接对本地文件计算(最稳定,无依赖)
gen_sri() {
  openssl dgst -sha384 -binary "$1" | openssl base64 -A
}
echo "sha384-$(gen_sri ./dist/app.min.js)"

# 方式 B:Node.js 一行脚本
node -e '
const fs=require("fs"),c=require("crypto");
const f=process.argv[1];
const h=c.createHash("sha384").update(fs.readFileSync(f)).digest("base64");
console.log("sha384-"+h);' ./dist/app.min.js

# 方式 C:远程资源直接校验(下载后计算,注意不要带压缩头)
curl -fsSL --compressed https://cdn.example.com/lib/jquery-3.7.1.min.js -o /tmp/jq.js
echo "sha384-$(gen_sri /tmp/jq.js)"

关键点:必须使用最终上线的那份文件计算。构建产物的 /* webpackChunkName */ 注释、日期戳、sourcemap 注释任何一处变化都会让哈希失效,因此哈希必须在构建完成后、上传前生成。

步骤二:构建流程自动注入 integrity

人工维护哈希不可持续,推荐在构建阶段自动写入。以下脚本把 HTML 中所有指向 assets/ 的 script/link 标签批量补上 integrity:

// tools/inject-sri.js  —— 构建后执行:node tools/inject-sri.js ./dist/index.html
const fs = require("fs");
const crypto = require("crypto");
const path = require("path");

const file = process.argv[2];
let html = fs.readFileSync(file, "utf8");
const root = path.dirname(file);
const algo = "sha384";

const tags = html.match(/<(script|link)\b[^>]*>/g) || [];
for (const tag of tags) {
  const m = tag.match(/(?:src|href)="([^"]+)"/);
  if (!m) continue;
  const url = m[1];
  if (!/^\/?assets\//.test(url)) continue;          // 仅处理本地资源
  const disk = path.join(root, url.replace(/^\//, ""));
  if (!fs.existsSync(disk)) { console.warn("skip missing", disk); continue; }
  if (/integrity=/.test(tag)) continue;

  const b64 = crypto.createHash(algo)
    .update(fs.readFileSync(disk)).digest("base64");
  let next = tag.replace(
    /(\s(?:src|href)="[^"]+")/,
    `$1 integrity="${algo}-${b64}"`
  );
  // 同源资源补 crossorigin,跨域资源必须补
  if (!/crossorigin/.test(next)) {
    next = next.replace(/>$/, ' crossorigin="anonymous">');
  }
  html = html.replace(tag, next);
}
fs.writeFileSync(file, html);
console.log("SRI injected:", file);

在 package.json 中把这一步挂到构建后钩子,避免漏执行:

{
  "scripts": {
    "build": "vite build",
    "postbuild": "node tools/inject-sri.js ./dist/index.html"
  }
}

步骤三:第三方 CDN 资源手动补齐

公共 CDN 通常会在文件页面直接给出官方 SRI 值,但不要盲信,必须自己下载核对一次。以下为常见资源的落地写法示例(哈希值务必替换为自行计算的结果):

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

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

建议把第三方资源全部落地到自建 CDN 或对象存储,这样哈希完全可控,也能规避上游 CDN 改版导致的突然失效。

步骤四:CSP 强制 SRI

单靠 integrity 属性是可被绕过的(攻击者可以删掉该属性),用 CSP 把「必须带 SRI」变成不可绕过的策略:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' https://cdn.example.com;
  require-sri-for script style;
  object-src 'none';
  base-uri 'self';

Nginx 落地写法:

# /etc/nginx/conf.d/sri-csp.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' https://cdn.example.com; require-sri-for script style; object-src 'none'; base-uri 'self';" always;
add_header Cross-Origin-Resource-Policy "same-site" always;

注意require-sri-for 目前仅在部分基于 Chromium 的浏览器中实现,其他浏览器会忽略该指令。它的作用是「在支持的环境里把漏洞窗口关死」,应当与 integrity 属性同时部署,而不是二选一。

步骤五:资源不可变缓存与文件名指纹

内容变更必然导致哈希变更,因此需保证每个哈希对应唯一 URL,避免旧缓存与新媒体交叉污染:

# 构建产物带内容指纹:app.3f9a2c.min.js
# 上传到对象存储并设置长缓存,文件名变了自然回源
aws s3 cp ./dist/ s3://static-bucket/ --recursive \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "index.html"

# 自建 Nginx 侧
location ~* \.[0-9a-f]{8,}\.(js|css|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

配置验证

第一步,浏览器侧确认校验生效。打开页面开发者工具的 Network 面板,筛选 JS,查看 Response Headers 中是否存在 integrity 记录;或在 Console 执行:

[...document.querySelectorAll('script[src],link[rel=stylesheet]')]
  .filter(e => e.integrity)
  .map(e => ({
    url: (e.src || e.href).split('/').pop(),
    integrity: e.integrity.slice(0, 20) + '...',
    crossorigin: e.crossOrigin,
    ok: !e.crossOrigin ? 'MISSING-CORS' : 'ok'
  }))
  .forEach(x => console.log(x));

第二步,做一次负向验证——确认篡改确实会被拦截。在本地起一个 HTTP 服务,模拟资源内容被改:

# 1) 生成原始文件与哈希
mkdir -p /tmp/sri && cd /tmp/sri
printf 'console.log("original");\n' > lib.js
HASH="sha384-$(openssl dgst -sha384 -binary lib.js | openssl base64 -A)"
echo "$HASH"

# 2) 写入测试页面
cat > index.html <<EOF
<!doctype html><meta charset="utf-8">
<script src="/lib.js" integrity="$HASH" crossorigin="anonymous"></script>
EOF

# 3) 启动服务,页面应正常执行(Console 输出 original)
python3 -m http.server 8080

# 4) 模拟供应链投毒:替换文件内容
printf 'alert("hijacked");\n' > lib.js
# 5) 刷新页面,脚本必须被拒绝执行,Console 报错:
#    Failed to find a valid digest in the 'integrity' attribute for resource ...

第三步,用 curl 确认响应体未被上游改动。SRI 失败最常见的原因是 CDN 在传输中重写了内容,用以下命令从不同节点各取一次并比对哈希:

check() {
  curl -fsSL --compressed "$1" -o /tmp/x.bin
  echo "$(openssl dgst -sha384 -binary /tmp/x.bin | openssl base64 -A)  $2"
}
check https://cdn.example.com/lib.js 节点A
check https://cdn.example.com/lib.js 节点B
# 两行哈希必须完全一致,不一致说明 CDN 存在内容改写

第四步,确认 CSP 头已生效:

curl -sI https://www.example.com/ | grep -i "content-security-policy"
# 应包含 require-sri-for script style

第五步,批量巡检全站是否还有未加 integrity 的外部资源:

curl -s https://www.example.com/ \
  | grep -Eo '<(script|link)[^>]+>' \
  | grep -E 'https?://' \
  | grep -v integrity \
  || echo "OK: 无未受保护的外部资源"

常见问题

FAQ 1:加了 integrity 后资源全部加载失败,控制台报 digest 不匹配,怎么排查?

按以下顺序定位,命中率最高的排在最前:

  • 缺少 crossorigin:跨域资源未声明 crossorigin="anonymous" 时,浏览器在 CORS 模式下拿不到响应体,SRI 必然失败。这是最高频的原因,且报错信息具有误导性;
  • 上游返回了不同内容:CDN 的自动压缩、Brotli 与 gzip 混用、图片/字体优化、JS 自动混淆功能都可能改写字节。用步骤三的 curl 脚本从两个节点比对哈希即可确认;
  • 哈希算法与长度不匹配sha384- 前缀必须与 base64 摘要长度对应(sha384 为 48 字节,base64 后 64 字符)。把 sha256 的摘要贴上 sha384 前缀是最容易犯的错误;
  • 对最终文件计算了哈希:在源码上算哈希、上传的是构建产物,两者内容不同。哈希必须在构建后、上传前生成;
  • CDN 返回了 WAF 拦截页或 302 跳转页:被 WAF 拦截后浏览器收到的是 HTML 错误页,内容自然不匹配。抓包确认 HTTP 状态与 Content-Type。

FAQ 2:使用了自动注入脚本,如何保证新增资源不会漏掉?

三道保险叠加,缺口基本可被封住:

  • 构建钩子:把 inject-sri 挂到 postbuild,构建即校验,漏执行会体现在产物中;
  • CI 校验:在流水线里做静态断言,发现未带 integrity 的外链直接让构建失败。核心逻辑就是下面这条 grep,命中即退出非零状态:
# .github/workflows or any CI
- name: Assert all external assets carry SRI
  run: |
    if grep -Eo '<(script|link)[^>]+>' dist/index.html \
       | grep -E 'https?://' | grep -v integrity; then
      echo "::error::存在未配置 integrity 的外部资源"; exit 1
    fi
  • 运行时告警:在页面中埋一段监听,发现未受保护资源时上报日志,覆盖通过 JS 动态插入的标签:
new MutationObserver(ms => {
  for (const m of ms) for (const n of m.addedNodes) {
    if (n.tagName === 'SCRIPT' && n.src && !n.integrity
        && !n.src.startsWith(location.origin)) {
      navigator.sendBeacon('/api/sri-alert', JSON.stringify({ src: n.src }));
    }
  }
}).observe(document.documentElement, { childList: true, subtree: true });

另外建议把「后端模板白名单」一并建立:凡是模板里出现的外部资源域名,必须同时在 SRI 白名单配置文件中登记,由 CI 比对两份清单,防止有人手工插入外链绕过构建流程。

FAQ 3:动态加载的脚本能加 SRI 吗?

可以。通过 document.createElement 创建的 script 元素支持 integrity 属性,但必须在该元素插入文档前设置:

function loadVerified(src, hash) {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.integrity = hash;              // 必须在 append 之前设置
    s.crossOrigin = 'anonymous';
    s.onload = resolve;
    s.onerror = () => reject(new Error('SRI blocked: ' + src));
    document.head.appendChild(s);
  });
}
loadVerified('https://cdn.example.com/chart.js', 'sha384-')
  .then(() => renderChart())
  .catch(e => console.error(e.message));

注意 evalnew FunctionsetTimeout('字符串') 执行的代码不在 SRI 覆盖范围内,这类写法应通过 CSP 的 'unsafe-eval' 缺失来禁止,而不是依赖 SRI。

总结

  • 哈希在构建后生成:对最终上线文件计算 sha384 摘要,跨域资源必须与 crossorigin="anonymous" 成对出现;
  • 注入自动化:把 inject-sri 挂到构建后钩子,配合 CI 静态断言,杜绝手工遗漏;
  • CSP 兜底require-sri-for script style 在支持该指令的浏览器上把「删除 integrity 属性」这条绕过路径关闭;
  • 缓存与指纹绑定:内容指纹文件名 + immutable 长缓存,让哈希与 URL 一一对应,避免旧缓存与新哈希交叉污染;
  • 双向验证:既验证正常资源可加载,也用一次篡改模拟验证恶意内容确实被拒绝执行,只做前者等于没验证。

SRI 的部署成本主要集中在前端构建改造上,一旦自动化完成,后续每次发布都是零额外人力。对存在大量第三方资源引用的站点,建议把本文的三道保险(构建钩子、CI 断言、运行时告警)一并落地,形成可长期维持的完整性防线。