subresource-integrity/sri-dynamic-loader
GitHub: subresource-integrity/sri-dynamic-loader
一个零依赖的浏览器端库,在运行时动态加载第三方脚本和样式表时强制执行子资源完整性校验,默认拒绝未验证的加载并提供多 CDN 回退与失败遥测。
Stars: 0 | Forks: 0
# sri-dynamic-loader
在运行时加载第三方脚本和样式表,强制执行子资源完整性(SRI),默认采用失败即关闭(fail-closed)策略,支持多 CDN 回退链和完整性失败遥测。零运行时依赖。
静态标记让完整性变得简单:你只需编写一次 `
```
**导入 ESM bundle。** `dist/sri-dynamic-loader.js`(可读版)或 `dist/sri-dynamic-loader.esm.min.js`(压缩版)是没有依赖项的单文件:
```
```
**克隆并导入源码。** `src/` 是普通的 ES 模块,无需构建步骤,这也是最容易阅读和打补丁的形式:
```
$ git clone https://github.com/subresource-integrity/sri-dynamic-loader.git
$ cd sri-dynamic-loader
$ npm install # esbuild + eslint, both dev-only
$ npm run check # lint, test, build
```
TypeScript 声明文件位于 `types/index.d.ts` 中,并从 `package.json` 中引用。
当前构建的测量大小(`npm run build` 会打印此表并写入 `dist/sizes.json`):
```
$ npm run build
file raw gzip brotli
---------------------------------- ------- ------ ------
dist/sri-dynamic-loader.js 27295 B 7887 B 6854 B
dist/sri-dynamic-loader.min.js 15489 B 6203 B 5452 B
dist/sri-dynamic-loader.esm.min.js 14987 B 5994 B 5247 B
```
其中很大一部分是错误消息文本。诊断是本库的核心目的,因此它们不会被 tree-shaken 掉;如果你只需要加载器,通过你自己的打包器从 `src/index.js` 导入就会丢弃任何你没有引用的代码。
## 用法
### 锁定的加载,默认失败即关闭
```
import { loadScript } from './src/index.js';
await loadScript('https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', {
integrity: 'sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs',
});
```
省略 `integrity`,则不会注入任何内容:
```
UnverifiedLoadError: refusing to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
without integrity metadata. Pass an integrity value, or pass allowUnverified: true if this
resource genuinely cannot be pinned.
```
传入格式错误的值,它会在元素存在之前被拒绝,因为浏览器会忽略它并照常加载脚本:
```
IntegrityFormatError: integrity digest for sha384 decodes to 32 bytes, expected 48. A hex digest
pasted in place of base64 is the usual cause; SRI uses base64 of the raw binary digest.
```
### 跨 CDN 的回退链
每个源都带有自己的 URL 和摘要,因为镜像通常是具有不同哈希值的不同构建版本。源会按顺序尝试,第一个加载成功的源即为最终结果。
```
const result = await loadScript([
{ url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: 'https://unpkg.com/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: '/vendor/jquery-3.7.1.min.js', integrity: 'sha384-9tPm…' },
]);
result.url; // the source that succeeded
result.attempts; // 3
result.failures; // the two errors from the CDNs, in order
```
如果所有源都失败,你会得到一个包含所有错误的 `AllSourcesFailedError`,并且 `describe()` 会将它们全部渲染出来:
```
all 2 sources failed for this script
1. https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
integrity-mismatch: failed to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js.
The response was fetched in full but the element still errored, which means the browser
rejected it after download. An integrity digest that does not match the bytes is by far the
most common cause; a missing or wrong crossorigin attribute produces the same symptom because
an opaque response can never satisfy an integrity check.
2. https://unpkg.com/jquery@3.7.1/dist/jquery.min.js
integrity-mismatch: failed to load https://unpkg.com/jquery@3.7.1/dist/jquery.min.js.
…
```
### 源注册表
在一个文件中声明所有锁定的 URL 和摘要,这样轮换哈希只需修改一行代码,而任何在别处游离的未锁定 `loadScript()` 调用在代码审查中都会非常显眼。定义会提前进行验证:错误的摘要会在启动时抛出异常,而不是在第一次需要该脚本时。
```
import { defineSources, load } from './src/index.js';
defineSources({
jquery: [
{ url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: '/vendor/jquery-3.7.1.min.js', integrity: 'sha384-9tPm…' },
],
stripe: {
sources: [{ url: 'https://js.stripe.com/v3/', integrity: 'sha384-…', allowUnverified: false }],
options: { timeout: 8000 },
},
theme: {
kind: 'style',
sources: [{ url: 'https://cdn.example/theme.css', integrity: 'sha384-…' }],
},
});
await load('jquery');
await load('theme');
```
### 样式表
```
await loadStyle('https://cdn.example/theme.css', { integrity: 'sha384-…', media: 'screen' });
```
`` 的 load 和 error 事件是此领域中最不可靠的部分:过去,引擎对于缓存的样式表通常会保持静默,而且当样式表因完整性检查被拒绝时,一些引擎仍然不会触发任何事件,而不是触发 `error`。因此,`loadStyle` 还会轮询 `link.sheet`,该属性只有在样式表被获取、完成完整性检查和解析后才会被填充,并在检查失败时保持为 `null`。因此,从未应用的样式表会抛出 `LoadTimeoutError`,而不是永远挂起。请为样式表加载设置一个你愿意等待的 `timeout`。
### 预实验验证,以获得明确的答案
SRI 不匹配会作为一个没有任何细节的裸 `error` 事件到达你的代码,这与 DNS 失败完全相同——浏览器不会提供更多信息,因为提供更多信息会导致跨源信息泄露。`verify: 'preflight'` 会获取资源,使用 `crypto.subtle` 对其进行哈希处理,并在注入任何内容之前进行比较:
```
try {
await loadScript(url, { integrity: expected, verify: 'preflight' });
} catch (error) {
const mismatch = error.errors[0];
mismatch.reason; // 'integrity-mismatch'
mismatch.expected; // ['sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs']
mismatch.actual; // ['sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC']
}
```
这在冷缓存时会产生一次额外的请求,并且它需要 CORS 和安全上下文,因此请将其保留用于那些明确诊断比几毫秒的时间更有价值的资源。
### 遥测
```
await loadScript(sources, {
onViolation(details) {
navigator.sendBeacon('/telemetry/sri', JSON.stringify({
url: details.url,
reason: details.reason, // integrity-mismatch | csp-blocked | network | timeout | …
fatal: details.fatal, // true only when no source was left to try
position: `${details.sourceIndex + 1}/${details.sourceCount}`,
}));
},
});
```
该钩子会在每个失败的源(包括非致命错误)触发一次,因此一个每次都静默回退到镜像的链仍然会被显示出来。抛出异常的钩子会被吞没:损坏的遥测绝不能将可恢复的 CDN 故障转变为未处理的拒绝。
### 可运行示例
```
$ npm run example
sri-dynamic-loader example: http://localhost:8000/
press ctrl-c to stop
```
该页面演示了成功的锁定加载、通过哈希不匹配故意触发的向镜像回退、指明了预期和实际摘要的预实验验证、样式表加载、失败即关闭的默认行为,以及累积所有错误的遥测钩子。它必须通过 HTTP 提供服务,而不是作为 `file://` URL 打开:完整性、CORS 和 `crypto.subtle` 在 file 协议上的行为都有所不同,并且 localhost 被算作安全上下文。
## 工作原理
**属性顺序至关重要。** 元素在设置 `src`/`href` 之前已完全配置好,而设置 `src`/`href` 是每条路径中的最后一次修改。在事后设置 `integrity` 或 `crossorigin` 对已经在传输中的请求没有任何影响,而这是一种悄无声息地完全失去强制执行能力的方式。测试套件断言 `src` 是写入的最后一个属性。
**注入前验证。** integrity 字符串被解析为算法/摘要对,算法会与 SRI 定义的三种算法(`sha256`、`sha384`、`sha512` —— 不是 sha1,不是 md5,也不是 sha3)进行核对,并且 base64 会被解码以确认其长度对于该算法是否正确:32、48 或 64 字节。将十六进制摘要粘贴到需要 base64 的地方是最常见的错误,在这里会被捕获。浏览器在遇到无法解析的 integrity 属性时,会将该资源视为未锁定,因此在此处大声报错正是其整体的安全特性。
**只有最强的算法才算数。** 浏览器会选择属性中存在的最强算法,并要求该算法至少有一个值匹配;它旁边的较弱值会被忽略。预实验验证也适用相同的规则,而不是接受浏览器本会丢弃的 `sha256`。
**无需额外流量的故障诊断。** 三个信号可以缩小裸 `error` 事件的范围:
1. 文档上的 `securitypolicyviolation` 事件。如果 CSP 屏蔽了该 URL,某个事件会指明它,这是一个*确定的*诊断——integrity 属性根本从未被评估过。
2. Resource Timing。被获取然后被完整性检查拒绝的资源仍然会产生一个带有已完成响应的 `PerformanceResourceTiming` 条目;而从未连接的资源则不会。一个带有 `responseEnd > 0` 的条目加上一个 `error` 事件意味着字节已到达但下游某些环节拒绝了它,这绝大多数情况下是完整性不匹配——或者是缺少 `crossorigin`,这看起来完全一样,因为不透明响应永远无法满足完整性检查。
3. 经过的时间,仅作为微弱的决胜条件。
每个错误都带有 `reason` 以及 `certain`、`likely` 或 `unknown` 的 `confidence`,因此没有任何内容会夸大平台实际告诉我们的信息。当预实验已经证明摘要是正确的时,`error` 事件会被明确地*不*归咎于完整性。
**去重。** 加载请求会根据类型加上绝对 URL 进行缓存,因此对同一脚本的两次并发调用会共享一个 promise 和一个 DOM 元素,而已经成功的 URL 会立即解析并带有 `cached: true`。失败永远不会被缓存,因此在 CDN 恢复后重试可以成功,回退链也能起作用。`reload: true` 会绕过缓存。
**清理。** 超时和中止都会在拒绝之前将 pending 的元素从文档中移除。中止会停止整个链,而不是前进到下一个镜像:这是调用者的决定,而不是源的属性。
## 选项
可按调用设置,或在链中按源设置,其中按源设置优先。标有 · 的行可在单个源上进行设置。
| 选项 | · | 类型 | 默认值 | 用途 |
| --- | --- | --- | --- | --- |
| `integrity` | · | `string` | — | SRI 元数据,例如 `sha384-…`。允许空格分隔的值。除非设置了 `allowUnverified`,否则为必填项。 |
| `allowUnverified` | · | `boolean` | `false` | 用于确实无法锁定的资源的逃生舱。如果不设置,未锁定的加载将会抛出异常。 |
| `crossOrigin` | · | `'anonymous' \| 'use-credentials' \| null` | `'anonymous'` | `crossorigin` 属性。`null` 表示省略它,对于具有完整性的跨源 URL,这种做法会被拒绝。 |
| `verify` | · | `'none' \| 'preflight'` | `'none'` | `preflight` 会在注入前使用 WebCrypto 对字节进行哈希处理。需要 CORS 和安全上下文。 |
| `module` | · | `boolean` | `false` | 将脚本作为 `type="module"` 注入。 |
| `nonce` | · | `string` | — | CSP nonce,同时作为内容属性和 IDL 属性设置。 |
| `attributes` | · | `Record` | `{}` | 额外属性,在 `src`/`href` 之前写入。 |
| `referrerPolicy` | · | `string` | — | `referrerpolicy` 属性。 |
| `media` | · | `string` | — | `media` 属性,仅用于样式表。 |
| `timeout` | | `number` | `15000` | 超时毫秒数,超过此时间元素将被移除并拒绝加载。设为 `0` 可禁用。 |
| `signal` | | `AbortSignal` | — | 取消加载并停止链。 |
| `reload` | | `boolean` | `false` | 绕过去重缓存并注入新元素。 |
| `onViolation` | | `(details) => void` | — | 针对每个失败的源调用一次,无论是否致命。 |
| `document` | | `Document` | `globalThis.document` | 目标文档;适用于 iframe 和测试。 |
| `performance` | | `Performance` | `globalThis.performance` | 可注入,用于故障诊断。 |
| `fetchImpl` / `cryptoImpl` | | | 全局变量 | 可注入,由预实验验证使用。 |
### 失败原因
| `reason` | 含义 |
| --- | --- |
| `unverified` | 没有完整性值且未设置 `allowUnverified`。未注入任何内容。 |
| `integrity-format` | integrity 字符串格式错误,或使用了非 SRI 算法或长度错误的摘要。 |
| `integrity-mismatch` | 预实验计算出不同的摘要,或元素在完整下载后报错。 |
| `csp-blocked` | `securitypolicyviolation` 事件指明了此 URL。确定。 |
| `network` | 请求未完成:DNS 错误、离线、连接重置、扩展程序屏蔽。 |
| `load-error` | 元素报错且信号不明确。 |
| `timeout` | 超过 `timeout`;元素已被移除。 |
| `aborted` | 通过 `AbortSignal` 取消。 |
| `all-sources-failed` | 聚合包装器;请参阅 `.errors` 和 `.describe()`。 |
| `missing-crossorigin` | 跨源源具有完整性但 `crossOrigin: null`,永远无法通过。 |
## 持续集成
`.github/workflows/ci.yml` 会在 Node 20、22 和 24 上运行 lint、测试和构建,如果已提交的 `dist/` 不再匹配其源码,则构建失败——因为引入的 bundle 是大多数消费者直接复制的内容,所以它绝不能发生偏移。
```
$ npm test
ℹ tests 60
ℹ pass 60
ℹ fail 0
```
测试使用 Node 内置的 `node:test` 运行器,针对 `test/dom-stub.js` 中的一个小型 DOM stub 运行。使用 jsdom 也可以,但它既不获取资源也不实现 SRI,因此对于有趣的情况——完整下载*之后*报错的元素——无论如何都必须手动模拟。stub 保持了测试套件的零依赖性,并使库所接触的 DOM 接口变得明确:`createElement`、`setAttribute`、`appendChild`、`removeChild` 和 `addEventListener`,仅此而已。预实验验证器会针对 Node 真实的 `crypto.subtle` 进行测试,并使用 `node:crypto` 进行交叉检查。
## 延伸阅读
关于本库旨在解决的背景问题:
- [为运行时注入的脚本添加完整性](https://www.subresource-integrity.com/asset-hashing-dynamic-script-injection/dynamic-script-loading-patterns/adding-integrity-to-runtime-injected-scripts/) —— 为什么动态创建的元素会失去完整性,以及保持完整性的属性顺序。
- [实现具有完整性的动态脚本加载器](https://www.subresource-integrity.com/asset-hashing-dynamic-script-injection/dynamic-script-loading-patterns/implementing-dynamic-script-loaders-with-integrity/) —— 本库所形式化的加载器模式,包括 promise 和缓存语义。
- [具有多个 CDN 源的 SRI 回退](https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/graceful-fallback-strategies/sri-fallback-with-multiple-cdn-sources/) —— 设计每个镜像都带有各自摘要的回退链。
- [使用 onerror 处理程序处理 SRI 失败](https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/graceful-fallback-strategies/handling-sri-failures-with-onerror-handlers/) —— `error` 事件能告诉你什么,又不能告诉你什么。
- [调试 SRI 哈希不匹配错误](https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/browser-enforcement-security-boundaries/debugging-sri-hash-mismatch-errors/) —— 弄清楚不匹配是因为轮换的构建、代理重写还是缺少 `crossorigin`。
- [用于 SRI 的 SHA-256 与 SHA-384 与 SHA-512 对比](https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/understanding-cryptographic-hash-algorithms/sha-256-vs-sha-384-vs-sha-512-for-sri/) —— 为什么 sha384 是明智的默认选择,以及浏览器如何在多个值之间做出选择。
- [使用 SRI 配置内容安全策略](https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/browser-enforcement-security-boundaries/configuring-content-security-policy-with-sri/) —— CSP 和完整性如何交互,以及为什么 CSP 屏蔽不是哈希不匹配。
- [使用 Reporting API 收集 CSP 违规报告](https://www.subresource-integrity.com/runtime-policy-enforcement-trusted-types/security-reporting-violation-telemetry/collecting-csp-violation-reports-with-the-reporting-api/) —— `onViolation` 钩子旨在提供的报告管道。
## 许可证
MIT。请参阅 [LICENSE](LICENSE)。
与 [子资源完整性与供应链加固参考](https://www.subresource-integrity.com) 一并维护。
标签:JavaScript库, SRI, Web安全, 数据可视化, 自定义脚本, 蓝队分析, 资源加载