vite-plugin-sri4 4.2.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -17
- package/dist/index.cjs +153 -78
- package/dist/index.js +153 -78
- package/package.json +1 -1
- package/types/index.d.ts +14 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vite-plugin-sri4
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+
[](https://www.npmjs.com/package/vite-plugin-sri4)
|
|
4
4
|
[](https://codecov.io/gh/7a6163/vite-plugin-sri4)
|
|
5
5
|

|
|
6
6
|
|
|
@@ -13,6 +13,7 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
13
13
|
- [Usage](#usage)
|
|
14
14
|
- [Plugin Options](#plugin-options)
|
|
15
15
|
- [Dynamic Routes](#dynamic-routes)
|
|
16
|
+
- [External Resources](#external-resources)
|
|
16
17
|
- [When SRI Actually Helps](#when-sri-actually-helps)
|
|
17
18
|
- [How It Attaches Hashes](#how-it-attaches-hashes)
|
|
18
19
|
- [Example Project](#example-project)
|
|
@@ -26,8 +27,8 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
26
27
|
|
|
27
28
|
- **Automatic SRI Generation:** Computes SRI hashes for assets (chunks and files) using a configurable algorithm (default is `sha384`).
|
|
28
29
|
- **HTML Injection:** Automatically injects `integrity` and `crossorigin` attributes into `<script>` and `<link>` tags in your HTML.
|
|
29
|
-
- **
|
|
30
|
-
- **Bypass Domains:**
|
|
30
|
+
- **External Resource Gating:** A resource on someone else's origin is hashed only when it is reachable, answers `Access-Control-Allow-Origin: *`, and its origin declares the URL immutable. Anything else is left alone with a warning naming the reason — a hash pins one snapshot of bytes, so it is only correct on a URL whose bytes never change. See [External resources](#external-resources).
|
|
31
|
+
- **Bypass and Trust Domains:** `bypassDomains` to leave a host alone, `trustDomains` to hash one whose headers do not declare it stable, plus a `skip-sri` attribute to opt out a single tag.
|
|
31
32
|
- **Public Directory Support:** Assets served verbatim from `publicDir` are hashed from disk, not just bundle outputs.
|
|
32
33
|
- **Zero Dependencies:** No runtime dependencies, and TypeScript definitions are included.
|
|
33
34
|
- **Missing Asset Handling:** Configurable warning suppression for missing assets.
|
|
@@ -94,7 +95,9 @@ Output:
|
|
|
94
95
|
* `crossorigin` (string):
|
|
95
96
|
Value for the injected `crossorigin` attribute: `anonymous` (default) or `use-credentials`. Use the latter for a CDN that requires cookies or HTTP auth. Tags that already declare a `crossorigin` are left alone.
|
|
96
97
|
* `bypassDomains` (Array<string>):
|
|
97
|
-
|
|
98
|
+
Hostnames to leave untouched, subdomains included. Use it to silence the warning for a host you have decided not to protect. See [External resources](#external-resources).
|
|
99
|
+
* `trustDomains` (Array<string>):
|
|
100
|
+
Hostnames whose bytes you vouch for, subdomains included. An external resource is only hashed when its origin declares the URL immutable; a host listed here is hashed regardless. For a stable host that does not set the header — not for forcing SRI onto a vendor's rolling URL. See [External resources](#external-resources).
|
|
98
101
|
* `ignoreMissingAsset` (boolean):
|
|
99
102
|
When true, warns instead of failing the build for assets found in neither the bundle nor `publicDir`. Default is `false`, which fails the build rather than shipping a tag with no integrity.
|
|
100
103
|
* `logLevel` (string):
|
|
@@ -135,6 +138,77 @@ This is the only mechanism available when the build produces no HTML asset.
|
|
|
135
138
|
|
|
136
139
|
Hashes are computed during the build. A plugin that mutates chunk contents after this one (`@vitejs/plugin-legacy`, compression plugins that rewrite in place) would invalidate them, so the plugin re-hashes every file it touched in `writeBundle` and fails the build if anything drifted. You get a build error rather than a page that only breaks in the browser.
|
|
137
140
|
|
|
141
|
+
## External resources
|
|
142
|
+
|
|
143
|
+
The `writeBundle` drift check above covers **your own build outputs only**. Everything you build is hashed locally — bundle chunks and assets from their bytes, `public/` files from disk, and, with an absolute `base`, your own CDN URLs from the bundle rather than the network — so none of it depends on a server being reachable or honest at build time.
|
|
144
|
+
|
|
145
|
+
An external URL pointing at someone else's origin is different. It is fetched **once, at build time, from your build machine**, and the hash is taken from that copy. An `integrity` attribute pins those bytes forever, so it is only correct on a URL whose bytes never change — and the origin is the only party that knows whether that is true.
|
|
146
|
+
|
|
147
|
+
So the plugin asks it. An external resource is hashed only when **all three** hold:
|
|
148
|
+
|
|
149
|
+
1. **`HEAD` succeeds.** Otherwise there is nothing to check.
|
|
150
|
+
2. **`Access-Control-Allow-Origin: *`.** Injecting `integrity` also injects `crossorigin`, so a response scoped to one specific origin — or to none — would turn a working resource into a blocked one.
|
|
151
|
+
3. **The origin declares the URL immutable**: `Cache-Control: immutable`, or a `max-age` of a year or more — and nothing in the same header contradicting it. `private`, `no-store` and `no-cache` each veto it: freshness and shareability are orthogonal, so a per-client response can carry a long `max-age`, and `no-cache, max-age=<long>` is a real CDN spelling of "cache it, but revalidate every time". Or the host is in `trustDomains`.
|
|
152
|
+
|
|
153
|
+
Anything else is left alone, with a warning naming the URL and the reason. Nothing ships without integrity silently.
|
|
154
|
+
|
|
155
|
+
### Why immutability, and not a list of bad origins
|
|
156
|
+
|
|
157
|
+
Because the list is never finished. Version-pinned URLs and rolling ones are two clean clusters, and the CDNs drew the line themselves:
|
|
158
|
+
|
|
159
|
+
| URL | `Cache-Control` | |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `cdnjs …/jquery/3.7.1/jquery.min.js` | `max-age=30672000, immutable` | hashed |
|
|
162
|
+
| `jsdelivr …/bootstrap@5.3.3/…` | `max-age=31536000, immutable` | hashed |
|
|
163
|
+
| `unpkg …/htmx.org@1.9.12/…` | `max-age=31536000` | hashed |
|
|
164
|
+
| `code.jquery.com/jquery-3.7.1.min.js` | `max-age=31536000` | hashed |
|
|
165
|
+
| `jsdelivr …/vue@3/…` (floating) | `max-age=604800` | skipped |
|
|
166
|
+
| `fonts.googleapis.com/css2?…` | `private, max-age=86400` | skipped |
|
|
167
|
+
| `plausible.io/js/script.js` | `public, max-age=86400` | skipped |
|
|
168
|
+
| `cdn.tailwindcss.com` | `max-age=14400` | skipped |
|
|
169
|
+
| `connect.facebook.net/en_US/sdk.js` | `public, max-age=1200` | skipped |
|
|
170
|
+
| `js.stripe.com/v3/` | `max-age=120` | skipped |
|
|
171
|
+
| `unpkg …/react@18/…` (floating) | `max-age=60` | skipped |
|
|
172
|
+
|
|
173
|
+
Nothing lands between 604800 and 30672000, so the threshold separates two clusters rather than splitting a spectrum.
|
|
174
|
+
|
|
175
|
+
A blacklist would have to catch every one of the bottom rows individually, and the ones that are ordinary `public` responses — `cdn.tailwindcss.com`, `plausible.io` — look exactly like a resource you *should* hash. Each gap ships a build that works today and breaks whenever that vendor deploys. The whitelist fails the other way: a resource you could have protected ships unprotected, and says so in the log.
|
|
176
|
+
|
|
177
|
+
`Vary` is deliberately not used. Google Fonts varies on `User-Agent` without declaring it there (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`), so a `Vary`-based check lets exactly that resource through.
|
|
178
|
+
|
|
179
|
+
### Getting a resource hashed
|
|
180
|
+
|
|
181
|
+
**Pin a version in the URL.** `unpkg.com/react@18.3.1/…` answers `max-age=31536000`; `unpkg.com/react@18/…` answers `max-age=60`. Same for jsdelivr. This is the fix, not a workaround — a floating URL and an integrity attribute are contradictory by construction.
|
|
182
|
+
|
|
183
|
+
**Or vouch for the host** when you know it is stable and it just does not say so. The shape to look for is a URL that already carries a version, served by an origin that simply sends no `Cache-Control` at all:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
https://js.tappaysdk.com/sdk/tpdirect/v5.19.2
|
|
187
|
+
|
|
188
|
+
access-control-allow-origin: *
|
|
189
|
+
(no cache-control header)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The version is in the path, so those bytes are as fixed as any `immutable` response — the origin just never says so. That is what `trustDomains` is for:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
sri({ trustDomains: ['js.tappaysdk.com'] })
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**When not to use it.** `trustDomains` overrides the one check that stands between you and a hash that stops matching. Do not point it at:
|
|
199
|
+
|
|
200
|
+
- **a URL without a version in it** — `js.stripe.com/v3/`, `cdn.tailwindcss.com`, `connect.facebook.net/en_US/sdk.js`. Stripe documents that `v3/` must not be pinned; forcing a hash onto it produces a page that works until their next deploy.
|
|
201
|
+
- **a floating range** — `unpkg.com/react@18/…` resolves to whatever 18.x is current.
|
|
202
|
+
- **a host that serves per-client responses** — `fonts.googleapis.com` answers `private` for a reason.
|
|
203
|
+
|
|
204
|
+
The test is not "do I trust this vendor". It is "will these exact bytes still be at this exact URL after their next release". If the answer comes from the URL itself, `trustDomains` is right; if it comes from hope, use `bypassDomains`.
|
|
205
|
+
|
|
206
|
+
**Or accept it and silence the warning** with `bypassDomains`. Third-party analytics and widget scripts are usually this case — they are built to auto-update, and there is nothing to pin:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
sri({ bypassDomains: ['www.googletagmanager.com', 'connect.facebook.net'] })
|
|
210
|
+
```
|
|
211
|
+
|
|
138
212
|
## When SRI Actually Helps
|
|
139
213
|
|
|
140
214
|
SRI is worth the most when your HTML and your assets have **different trust boundaries** - typically HTML served from your own origin and JS/CSS served from a CDN (`base: 'https://cdn.example.com/'`). If the CDN is compromised or a cache is poisoned, the integrity attribute in your origin-served HTML is what stops the browser from running the tampered file. That is the case this plugin is built for.
|
|
@@ -205,10 +279,11 @@ The example project shows:
|
|
|
205
279
|
- Consider `sha512` for maximum security
|
|
206
280
|
- Avoid `sha1` as it's considered cryptographically weak
|
|
207
281
|
|
|
208
|
-
2. **
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
- Use `bypassDomains` for
|
|
282
|
+
2. **External Resources**
|
|
283
|
+
- Pin a version in the URL. `unpkg.com/react@18.3.1/…` answers `max-age=31536000` and gets a hash; `unpkg.com/react@18/…` answers `max-age=60` and does not
|
|
284
|
+
- Serve your own assets with `Access-Control-Allow-Origin: *` and an immutable `Cache-Control`
|
|
285
|
+
- Use `bypassDomains` for a host you have decided not to protect — a vendor's auto-updating widget or analytics script
|
|
286
|
+
- Use `trustDomains` only for a host you control that is stable but does not say so in its headers
|
|
212
287
|
|
|
213
288
|
3. **Performance Optimization**
|
|
214
289
|
- Enable `ignoreMissingAsset` in development for faster builds
|
|
@@ -223,17 +298,21 @@ The example project shows:
|
|
|
223
298
|
|
|
224
299
|
### Common Issues
|
|
225
300
|
|
|
226
|
-
1. **
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
-
|
|
301
|
+
1. **An external resource has no integrity**
|
|
302
|
+
- Read the build log. Every skip warns and names its reason
|
|
303
|
+
- `does not declare this URL immutable` — the origin's `Cache-Control` is short, or carries `private` / `no-cache` / `no-store`. Pin a version in the URL, or see [Getting a resource hashed](#getting-a-resource-hashed)
|
|
304
|
+
- `Access-Control-Allow-Origin is absent` / `not "*"` — nothing to do at build time; `bypassDomains` silences it
|
|
305
|
+
|
|
306
|
+
2. **A local asset has no integrity**
|
|
307
|
+
- Check the file is in your build output, or in `publicDir`
|
|
308
|
+
- Verify the path in the tag matches, including `base`
|
|
309
|
+
- Enable debug mode to see per-resource decisions
|
|
230
310
|
|
|
231
|
-
|
|
232
|
-
-
|
|
233
|
-
-
|
|
234
|
-
- Check network tab for CORS headers
|
|
311
|
+
3. **The browser blocks a resource that has integrity**
|
|
312
|
+
- The bytes changed after the build. If it is your own output, a plugin ordered after this one rewrote it — the `writeBundle` drift check should have failed the build, so check the plugin order
|
|
313
|
+
- If it is external, the URL is not as immutable as its headers claim. Move it to `bypassDomains`
|
|
235
314
|
|
|
236
|
-
|
|
315
|
+
4. **Build Performance**
|
|
237
316
|
- Use `ignoreMissingAsset` if you have many external resources
|
|
238
317
|
- Disable debug mode in production
|
|
239
318
|
- Consider using a CDN for external resources
|
package/dist/index.cjs
CHANGED
|
@@ -49,20 +49,14 @@ class ResourceCache {
|
|
|
49
49
|
*/
|
|
50
50
|
class CacheManager {
|
|
51
51
|
constructor() {
|
|
52
|
-
this.urlSupportCache = new ResourceCache();
|
|
53
52
|
this.resourceCache = new ResourceCache();
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
getUrlSupportCache() {
|
|
57
|
-
return this.urlSupportCache
|
|
58
|
-
}
|
|
59
|
-
|
|
60
55
|
getResourceCache() {
|
|
61
56
|
return this.resourceCache
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
clearAll() {
|
|
65
|
-
this.urlSupportCache.clear();
|
|
66
60
|
this.resourceCache.clear();
|
|
67
61
|
}
|
|
68
62
|
}
|
|
@@ -72,14 +66,16 @@ class CacheManager {
|
|
|
72
66
|
const DEFAULT_TIMEOUT = 5000;
|
|
73
67
|
|
|
74
68
|
/**
|
|
75
|
-
*
|
|
69
|
+
* Does an external URL's host match one of `domains`, or a subdomain of one?
|
|
70
|
+
* Used by both `bypassDomains` and `trustDomains`.
|
|
76
71
|
*/
|
|
77
|
-
function
|
|
72
|
+
function matchesDomain(url, domains = [], logger = null) {
|
|
78
73
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
74
|
+
if (domains.length === 0) return false
|
|
79
75
|
|
|
80
76
|
try {
|
|
81
77
|
const urlObj = new URL(url);
|
|
82
|
-
return
|
|
78
|
+
return domains.some(domain =>
|
|
83
79
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
84
80
|
)
|
|
85
81
|
} catch (error) {
|
|
@@ -90,76 +86,106 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
|
|
|
90
86
|
}
|
|
91
87
|
}
|
|
92
88
|
|
|
89
|
+
// A year. The conventional encoding of "this URL's bytes will never change",
|
|
90
|
+
// and what every CDN puts on a version-pinned path.
|
|
91
|
+
const IMMUTABLE_MAX_AGE = 31536000;
|
|
92
|
+
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
94
|
+
* Does the origin declare this URL's bytes immutable - `Cache-Control:
|
|
95
|
+
* immutable`, or a max-age of a year or more - and nothing in the same header
|
|
96
|
+
* contradicting it?
|
|
97
|
+
*
|
|
98
|
+
* Measured, because the split is what makes this usable as a gate. Pinned
|
|
99
|
+
* third-party libraries, the case SRI actually exists for:
|
|
100
|
+
*
|
|
101
|
+
* cdnjs jquery/3.7.1 max-age=30672000, immutable
|
|
102
|
+
* jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
|
|
103
|
+
* unpkg htmx.org@1.9.12 max-age=31536000
|
|
104
|
+
* code.jquery.com 3.7.1 max-age=31536000
|
|
105
|
+
*
|
|
106
|
+
* Everything that rolls under a stable URL:
|
|
107
|
+
*
|
|
108
|
+
* jsdelivr vue@3 max-age=604800
|
|
109
|
+
* fonts.googleapis.com max-age=86400 (also `private`)
|
|
110
|
+
* plausible.io/js/script.js max-age=86400
|
|
111
|
+
* cdn.tailwindcss.com max-age=14400
|
|
112
|
+
* connect.facebook.net max-age=1200
|
|
113
|
+
* js.stripe.com/v3/ max-age=120
|
|
114
|
+
* unpkg react@18 max-age=60
|
|
115
|
+
*
|
|
116
|
+
* Nothing lands between 604800 and 30672000, so the threshold is not a
|
|
117
|
+
* balancing act - it separates two clusters the CDNs themselves created.
|
|
95
118
|
*/
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
function isImmutableResponse(cacheControl) {
|
|
120
|
+
if (!cacheControl) return false
|
|
121
|
+
|
|
122
|
+
let immutable = false;
|
|
123
|
+
|
|
124
|
+
for (const directive of cacheControl.split(',')) {
|
|
125
|
+
// Token equality, not substring: `x-immutable` is not this directive
|
|
126
|
+
const token = directive.trim().toLowerCase();
|
|
127
|
+
|
|
128
|
+
// These veto whatever else the header claims, and are checked against the
|
|
129
|
+
// whole header rather than returning early, because freshness and
|
|
130
|
+
// shareability are orthogonal - a per-client response can carry a long
|
|
131
|
+
// max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
|
|
132
|
+
// it, but revalidate every time", i.e. the bytes may have changed.
|
|
133
|
+
//
|
|
134
|
+
// The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
|
|
135
|
+
// only scope the directive to those headers, so vetoing on them is
|
|
136
|
+
// stricter than the spec requires. That is the right way to be wrong here:
|
|
137
|
+
// the cost is losing SRI on a resource that would have been fine, and it
|
|
138
|
+
// is logged. Not vetoing costs a page that only breaks in the browser.
|
|
139
|
+
if (
|
|
140
|
+
token === 'no-store' ||
|
|
141
|
+
token === 'private' || token.startsWith('private=') ||
|
|
142
|
+
token === 'no-cache' || token.startsWith('no-cache=')
|
|
143
|
+
) {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
113
146
|
|
|
114
|
-
|
|
115
|
-
// injecting crossorigin="anonymous"; if the server answers with a
|
|
116
|
-
// concrete origin that does not match wherever the HTML ends up being
|
|
117
|
-
// served from, that turns a working script into a blocked one. Skipping
|
|
118
|
-
// is the safe outcome, but say so at warn level - silence here is what
|
|
119
|
-
// makes an unprotected resource easy to miss.
|
|
120
|
-
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
121
|
-
const isSupported = response.ok && corsHeader === '*';
|
|
122
|
-
if (response.ok && corsHeader && corsHeader !== '*' && logger) {
|
|
123
|
-
logger.warn(
|
|
124
|
-
`Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
|
|
125
|
-
'so crossorigin="anonymous" cannot be verified at build time. ' +
|
|
126
|
-
'Add the domain to bypassDomains to silence this.'
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
urlSupportCache.set(url, isSupported);
|
|
130
|
-
return isSupported
|
|
131
|
-
} catch (error) {
|
|
132
|
-
lastError = error;
|
|
133
|
-
if (error.name === 'AbortError') {
|
|
134
|
-
if (logger) {
|
|
135
|
-
logger.warn(`Resource check timed out: ${url}`);
|
|
136
|
-
}
|
|
137
|
-
break // Don't retry timeouts
|
|
138
|
-
}
|
|
147
|
+
if (token === 'immutable') immutable = true;
|
|
139
148
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
if (token.startsWith('max-age=')) {
|
|
150
|
+
// RFC 9111 permits a quoted-string value: `max-age="31536000"`
|
|
151
|
+
const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
|
|
152
|
+
if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
|
|
144
153
|
}
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
logger.warn(`Failed to check resource support: ${url}`, lastError);
|
|
149
|
-
}
|
|
150
|
-
urlSupportCache.set(url, false);
|
|
151
|
-
return false
|
|
156
|
+
return immutable
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/**
|
|
155
|
-
*
|
|
160
|
+
* Resource check with retry mechanism
|
|
156
161
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Fetch an external resource and return its bytes, or null if it must not be
|
|
164
|
+
* hashed. The reason is always logged - a tag that silently ships without
|
|
165
|
+
* integrity is the thing that is easy to miss.
|
|
166
|
+
*
|
|
167
|
+
* One GET, not a HEAD probe followed by a GET. The headers the gates need
|
|
168
|
+
* arrive on the response that carries the bytes anyway, so probing separately
|
|
169
|
+
* doubled the requests and threw the useful copy away - and made the plugin
|
|
170
|
+
* depend on HEAD being served at all. It often is not: js.tappaysdk.com
|
|
171
|
+
* answers 403 to HEAD and 200 to GET, which used to read as "could not be
|
|
172
|
+
* checked" on a payment SDK, exactly the kind of script SRI is for.
|
|
173
|
+
*
|
|
174
|
+
* The cost is that a rejected resource is downloaded before it is rejected.
|
|
175
|
+
* That is the right side to lose on: the accepted case, which is every build
|
|
176
|
+
* that actually ships hashes, goes from two requests to one.
|
|
177
|
+
*/
|
|
178
|
+
async function fetchVerifiedResource(url, resourceCache, logger = null, trusted = false, retries = 1) {
|
|
159
179
|
if (resourceCache.has(url)) {
|
|
160
180
|
return resourceCache.get(url)
|
|
161
181
|
}
|
|
162
182
|
|
|
183
|
+
const reject = (message) => {
|
|
184
|
+
if (logger && message) logger.warn(message);
|
|
185
|
+
resourceCache.set(url, null);
|
|
186
|
+
return null
|
|
187
|
+
};
|
|
188
|
+
|
|
163
189
|
let lastError;
|
|
164
190
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
165
191
|
try {
|
|
@@ -167,10 +193,52 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
167
193
|
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
168
194
|
|
|
169
195
|
const response = await fetch(url, { signal: controller.signal });
|
|
196
|
+
|
|
170
197
|
clearTimeout(timeoutId);
|
|
171
198
|
|
|
172
199
|
if (!response.ok) {
|
|
173
|
-
|
|
200
|
+
return reject(
|
|
201
|
+
`Skipping SRI for ${url}: the server answered ${response.status}. ` +
|
|
202
|
+
'Add the domain to bypassDomains to silence this.'
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Only `*` can be verified at build time. Injecting integrity also means
|
|
207
|
+
// injecting crossorigin="anonymous"; if the server answers with a
|
|
208
|
+
// concrete origin that does not match wherever the HTML ends up being
|
|
209
|
+
// served from, that turns a working script into a blocked one.
|
|
210
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
211
|
+
if (corsHeader !== '*') {
|
|
212
|
+
return reject(
|
|
213
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
|
|
214
|
+
`${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
|
|
215
|
+
'cannot be verified at build time. ' +
|
|
216
|
+
'Add the domain to bypassDomains to silence this.'
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Reachable and CORS-eligible is not the same property as byte-stable.
|
|
221
|
+
// A hash pins one snapshot of bytes forever, so it is only safe on a URL
|
|
222
|
+
// whose bytes never change - and the origin is the only party that knows.
|
|
223
|
+
// Require it to say so rather than hunting for reasons to skip: a
|
|
224
|
+
// blacklist of known-bad origins is never finished (Google Fonts is
|
|
225
|
+
// `private`, but cdn.tailwindcss.com and plausible.io are ordinary
|
|
226
|
+
// `public` responses that roll just the same), and every gap in it ships
|
|
227
|
+
// a build that works today and breaks whenever the vendor deploys.
|
|
228
|
+
//
|
|
229
|
+
// Deliberately not `vary`: Google Fonts varies on User-Agent without
|
|
230
|
+
// declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
|
|
231
|
+
// so a vary-based gate would let that resource straight through.
|
|
232
|
+
const cacheControl = response.headers.get('cache-control');
|
|
233
|
+
if (!trusted && !isImmutableResponse(cacheControl)) {
|
|
234
|
+
return reject(
|
|
235
|
+
`Skipping SRI for ${url}: Cache-Control is ` +
|
|
236
|
+
`${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
|
|
237
|
+
'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
|
|
238
|
+
'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
|
|
239
|
+
'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
|
|
240
|
+
"vendor's rolling URL ships a page that breaks on their next deploy."
|
|
241
|
+
)
|
|
174
242
|
}
|
|
175
243
|
|
|
176
244
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
@@ -179,12 +247,10 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
179
247
|
} catch (error) {
|
|
180
248
|
lastError = error;
|
|
181
249
|
if (error.name === 'AbortError') {
|
|
182
|
-
|
|
183
|
-
logger.warn(`Resource fetch timed out: ${url}`);
|
|
184
|
-
}
|
|
185
|
-
break // Don't retry timeouts
|
|
250
|
+
return reject(`Skipping SRI for ${url}: the request timed out.`)
|
|
186
251
|
}
|
|
187
252
|
|
|
253
|
+
// Don't wait after the last failed attempt
|
|
188
254
|
if (attempt < retries) {
|
|
189
255
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
190
256
|
}
|
|
@@ -192,8 +258,9 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
192
258
|
}
|
|
193
259
|
|
|
194
260
|
if (logger) {
|
|
195
|
-
logger.warn(`
|
|
261
|
+
logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
|
|
196
262
|
}
|
|
263
|
+
resourceCache.set(url, null);
|
|
197
264
|
return null
|
|
198
265
|
}
|
|
199
266
|
|
|
@@ -339,15 +406,11 @@ async function calculateIntegrity(
|
|
|
339
406
|
const {
|
|
340
407
|
ignoreMissingAsset,
|
|
341
408
|
bypassDomains,
|
|
409
|
+
trustDomains,
|
|
342
410
|
hashAlgorithm,
|
|
343
411
|
hashedAssets
|
|
344
412
|
} = options;
|
|
345
413
|
|
|
346
|
-
// Skip specified domains
|
|
347
|
-
if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
|
|
348
|
-
return null
|
|
349
|
-
}
|
|
350
|
-
|
|
351
414
|
// With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
|
|
352
415
|
// own build output. Those must be hashed from the bundle, not fetched - the
|
|
353
416
|
// CDN may not have been deployed yet, and this is the very case SRI exists
|
|
@@ -356,12 +419,22 @@ async function calculateIntegrity(
|
|
|
356
419
|
const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
|
|
357
420
|
const fetchUrl = ownAsset ? null : externalUrl(url);
|
|
358
421
|
|
|
422
|
+
// Both domain options match the URL that would actually be fetched.
|
|
423
|
+
// `matchesDomain` needs a scheme, so matching the raw `url` silently missed
|
|
424
|
+
// every protocol-relative `//host/path` - and `trustDomains` below, which
|
|
425
|
+
// already saw the normalized form, would then disagree with `bypassDomains`
|
|
426
|
+
// about the same tag.
|
|
427
|
+
if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
|
|
359
431
|
let source;
|
|
360
432
|
let bundleFileName = null;
|
|
361
433
|
if (fetchUrl) {
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
434
|
+
const trusted = matchesDomain(fetchUrl, trustDomains, logger);
|
|
435
|
+
source = await fetchVerifiedResource(
|
|
436
|
+
fetchUrl, cacheManager.getResourceCache(), logger, trusted
|
|
437
|
+
);
|
|
365
438
|
if (!source) return null
|
|
366
439
|
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
367
440
|
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
@@ -852,6 +925,7 @@ function sri(options = {}) {
|
|
|
852
925
|
const {
|
|
853
926
|
ignoreMissingAsset = false,
|
|
854
927
|
bypassDomains = [],
|
|
928
|
+
trustDomains = [],
|
|
855
929
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
856
930
|
crossorigin = 'anonymous',
|
|
857
931
|
logLevel = 'warn',
|
|
@@ -914,6 +988,7 @@ function sri(options = {}) {
|
|
|
914
988
|
transformer = createTransformer({
|
|
915
989
|
ignoreMissingAsset,
|
|
916
990
|
bypassDomains,
|
|
991
|
+
trustDomains,
|
|
917
992
|
hashAlgorithm,
|
|
918
993
|
crossorigin,
|
|
919
994
|
hashedAssets
|
package/dist/index.js
CHANGED
|
@@ -45,20 +45,14 @@ class ResourceCache {
|
|
|
45
45
|
*/
|
|
46
46
|
class CacheManager {
|
|
47
47
|
constructor() {
|
|
48
|
-
this.urlSupportCache = new ResourceCache();
|
|
49
48
|
this.resourceCache = new ResourceCache();
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
getUrlSupportCache() {
|
|
53
|
-
return this.urlSupportCache
|
|
54
|
-
}
|
|
55
|
-
|
|
56
51
|
getResourceCache() {
|
|
57
52
|
return this.resourceCache
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
clearAll() {
|
|
61
|
-
this.urlSupportCache.clear();
|
|
62
56
|
this.resourceCache.clear();
|
|
63
57
|
}
|
|
64
58
|
}
|
|
@@ -68,14 +62,16 @@ class CacheManager {
|
|
|
68
62
|
const DEFAULT_TIMEOUT = 5000;
|
|
69
63
|
|
|
70
64
|
/**
|
|
71
|
-
*
|
|
65
|
+
* Does an external URL's host match one of `domains`, or a subdomain of one?
|
|
66
|
+
* Used by both `bypassDomains` and `trustDomains`.
|
|
72
67
|
*/
|
|
73
|
-
function
|
|
68
|
+
function matchesDomain(url, domains = [], logger = null) {
|
|
74
69
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
70
|
+
if (domains.length === 0) return false
|
|
75
71
|
|
|
76
72
|
try {
|
|
77
73
|
const urlObj = new URL(url);
|
|
78
|
-
return
|
|
74
|
+
return domains.some(domain =>
|
|
79
75
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
80
76
|
)
|
|
81
77
|
} catch (error) {
|
|
@@ -86,76 +82,106 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
|
|
|
86
82
|
}
|
|
87
83
|
}
|
|
88
84
|
|
|
85
|
+
// A year. The conventional encoding of "this URL's bytes will never change",
|
|
86
|
+
// and what every CDN puts on a version-pinned path.
|
|
87
|
+
const IMMUTABLE_MAX_AGE = 31536000;
|
|
88
|
+
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
90
|
+
* Does the origin declare this URL's bytes immutable - `Cache-Control:
|
|
91
|
+
* immutable`, or a max-age of a year or more - and nothing in the same header
|
|
92
|
+
* contradicting it?
|
|
93
|
+
*
|
|
94
|
+
* Measured, because the split is what makes this usable as a gate. Pinned
|
|
95
|
+
* third-party libraries, the case SRI actually exists for:
|
|
96
|
+
*
|
|
97
|
+
* cdnjs jquery/3.7.1 max-age=30672000, immutable
|
|
98
|
+
* jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
|
|
99
|
+
* unpkg htmx.org@1.9.12 max-age=31536000
|
|
100
|
+
* code.jquery.com 3.7.1 max-age=31536000
|
|
101
|
+
*
|
|
102
|
+
* Everything that rolls under a stable URL:
|
|
103
|
+
*
|
|
104
|
+
* jsdelivr vue@3 max-age=604800
|
|
105
|
+
* fonts.googleapis.com max-age=86400 (also `private`)
|
|
106
|
+
* plausible.io/js/script.js max-age=86400
|
|
107
|
+
* cdn.tailwindcss.com max-age=14400
|
|
108
|
+
* connect.facebook.net max-age=1200
|
|
109
|
+
* js.stripe.com/v3/ max-age=120
|
|
110
|
+
* unpkg react@18 max-age=60
|
|
111
|
+
*
|
|
112
|
+
* Nothing lands between 604800 and 30672000, so the threshold is not a
|
|
113
|
+
* balancing act - it separates two clusters the CDNs themselves created.
|
|
91
114
|
*/
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
function isImmutableResponse(cacheControl) {
|
|
116
|
+
if (!cacheControl) return false
|
|
117
|
+
|
|
118
|
+
let immutable = false;
|
|
119
|
+
|
|
120
|
+
for (const directive of cacheControl.split(',')) {
|
|
121
|
+
// Token equality, not substring: `x-immutable` is not this directive
|
|
122
|
+
const token = directive.trim().toLowerCase();
|
|
123
|
+
|
|
124
|
+
// These veto whatever else the header claims, and are checked against the
|
|
125
|
+
// whole header rather than returning early, because freshness and
|
|
126
|
+
// shareability are orthogonal - a per-client response can carry a long
|
|
127
|
+
// max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
|
|
128
|
+
// it, but revalidate every time", i.e. the bytes may have changed.
|
|
129
|
+
//
|
|
130
|
+
// The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
|
|
131
|
+
// only scope the directive to those headers, so vetoing on them is
|
|
132
|
+
// stricter than the spec requires. That is the right way to be wrong here:
|
|
133
|
+
// the cost is losing SRI on a resource that would have been fine, and it
|
|
134
|
+
// is logged. Not vetoing costs a page that only breaks in the browser.
|
|
135
|
+
if (
|
|
136
|
+
token === 'no-store' ||
|
|
137
|
+
token === 'private' || token.startsWith('private=') ||
|
|
138
|
+
token === 'no-cache' || token.startsWith('no-cache=')
|
|
139
|
+
) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
109
142
|
|
|
110
|
-
|
|
111
|
-
// injecting crossorigin="anonymous"; if the server answers with a
|
|
112
|
-
// concrete origin that does not match wherever the HTML ends up being
|
|
113
|
-
// served from, that turns a working script into a blocked one. Skipping
|
|
114
|
-
// is the safe outcome, but say so at warn level - silence here is what
|
|
115
|
-
// makes an unprotected resource easy to miss.
|
|
116
|
-
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
117
|
-
const isSupported = response.ok && corsHeader === '*';
|
|
118
|
-
if (response.ok && corsHeader && corsHeader !== '*' && logger) {
|
|
119
|
-
logger.warn(
|
|
120
|
-
`Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
|
|
121
|
-
'so crossorigin="anonymous" cannot be verified at build time. ' +
|
|
122
|
-
'Add the domain to bypassDomains to silence this.'
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
urlSupportCache.set(url, isSupported);
|
|
126
|
-
return isSupported
|
|
127
|
-
} catch (error) {
|
|
128
|
-
lastError = error;
|
|
129
|
-
if (error.name === 'AbortError') {
|
|
130
|
-
if (logger) {
|
|
131
|
-
logger.warn(`Resource check timed out: ${url}`);
|
|
132
|
-
}
|
|
133
|
-
break // Don't retry timeouts
|
|
134
|
-
}
|
|
143
|
+
if (token === 'immutable') immutable = true;
|
|
135
144
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
if (token.startsWith('max-age=')) {
|
|
146
|
+
// RFC 9111 permits a quoted-string value: `max-age="31536000"`
|
|
147
|
+
const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
|
|
148
|
+
if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
|
|
144
|
-
logger.warn(`Failed to check resource support: ${url}`, lastError);
|
|
145
|
-
}
|
|
146
|
-
urlSupportCache.set(url, false);
|
|
147
|
-
return false
|
|
152
|
+
return immutable
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
/**
|
|
151
|
-
*
|
|
156
|
+
* Resource check with retry mechanism
|
|
152
157
|
*/
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Fetch an external resource and return its bytes, or null if it must not be
|
|
160
|
+
* hashed. The reason is always logged - a tag that silently ships without
|
|
161
|
+
* integrity is the thing that is easy to miss.
|
|
162
|
+
*
|
|
163
|
+
* One GET, not a HEAD probe followed by a GET. The headers the gates need
|
|
164
|
+
* arrive on the response that carries the bytes anyway, so probing separately
|
|
165
|
+
* doubled the requests and threw the useful copy away - and made the plugin
|
|
166
|
+
* depend on HEAD being served at all. It often is not: js.tappaysdk.com
|
|
167
|
+
* answers 403 to HEAD and 200 to GET, which used to read as "could not be
|
|
168
|
+
* checked" on a payment SDK, exactly the kind of script SRI is for.
|
|
169
|
+
*
|
|
170
|
+
* The cost is that a rejected resource is downloaded before it is rejected.
|
|
171
|
+
* That is the right side to lose on: the accepted case, which is every build
|
|
172
|
+
* that actually ships hashes, goes from two requests to one.
|
|
173
|
+
*/
|
|
174
|
+
async function fetchVerifiedResource(url, resourceCache, logger = null, trusted = false, retries = 1) {
|
|
155
175
|
if (resourceCache.has(url)) {
|
|
156
176
|
return resourceCache.get(url)
|
|
157
177
|
}
|
|
158
178
|
|
|
179
|
+
const reject = (message) => {
|
|
180
|
+
if (logger && message) logger.warn(message);
|
|
181
|
+
resourceCache.set(url, null);
|
|
182
|
+
return null
|
|
183
|
+
};
|
|
184
|
+
|
|
159
185
|
let lastError;
|
|
160
186
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
161
187
|
try {
|
|
@@ -163,10 +189,52 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
163
189
|
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
164
190
|
|
|
165
191
|
const response = await fetch(url, { signal: controller.signal });
|
|
192
|
+
|
|
166
193
|
clearTimeout(timeoutId);
|
|
167
194
|
|
|
168
195
|
if (!response.ok) {
|
|
169
|
-
|
|
196
|
+
return reject(
|
|
197
|
+
`Skipping SRI for ${url}: the server answered ${response.status}. ` +
|
|
198
|
+
'Add the domain to bypassDomains to silence this.'
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Only `*` can be verified at build time. Injecting integrity also means
|
|
203
|
+
// injecting crossorigin="anonymous"; if the server answers with a
|
|
204
|
+
// concrete origin that does not match wherever the HTML ends up being
|
|
205
|
+
// served from, that turns a working script into a blocked one.
|
|
206
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
207
|
+
if (corsHeader !== '*') {
|
|
208
|
+
return reject(
|
|
209
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
|
|
210
|
+
`${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
|
|
211
|
+
'cannot be verified at build time. ' +
|
|
212
|
+
'Add the domain to bypassDomains to silence this.'
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Reachable and CORS-eligible is not the same property as byte-stable.
|
|
217
|
+
// A hash pins one snapshot of bytes forever, so it is only safe on a URL
|
|
218
|
+
// whose bytes never change - and the origin is the only party that knows.
|
|
219
|
+
// Require it to say so rather than hunting for reasons to skip: a
|
|
220
|
+
// blacklist of known-bad origins is never finished (Google Fonts is
|
|
221
|
+
// `private`, but cdn.tailwindcss.com and plausible.io are ordinary
|
|
222
|
+
// `public` responses that roll just the same), and every gap in it ships
|
|
223
|
+
// a build that works today and breaks whenever the vendor deploys.
|
|
224
|
+
//
|
|
225
|
+
// Deliberately not `vary`: Google Fonts varies on User-Agent without
|
|
226
|
+
// declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
|
|
227
|
+
// so a vary-based gate would let that resource straight through.
|
|
228
|
+
const cacheControl = response.headers.get('cache-control');
|
|
229
|
+
if (!trusted && !isImmutableResponse(cacheControl)) {
|
|
230
|
+
return reject(
|
|
231
|
+
`Skipping SRI for ${url}: Cache-Control is ` +
|
|
232
|
+
`${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
|
|
233
|
+
'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
|
|
234
|
+
'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
|
|
235
|
+
'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
|
|
236
|
+
"vendor's rolling URL ships a page that breaks on their next deploy."
|
|
237
|
+
)
|
|
170
238
|
}
|
|
171
239
|
|
|
172
240
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
@@ -175,12 +243,10 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
175
243
|
} catch (error) {
|
|
176
244
|
lastError = error;
|
|
177
245
|
if (error.name === 'AbortError') {
|
|
178
|
-
|
|
179
|
-
logger.warn(`Resource fetch timed out: ${url}`);
|
|
180
|
-
}
|
|
181
|
-
break // Don't retry timeouts
|
|
246
|
+
return reject(`Skipping SRI for ${url}: the request timed out.`)
|
|
182
247
|
}
|
|
183
248
|
|
|
249
|
+
// Don't wait after the last failed attempt
|
|
184
250
|
if (attempt < retries) {
|
|
185
251
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
186
252
|
}
|
|
@@ -188,8 +254,9 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
188
254
|
}
|
|
189
255
|
|
|
190
256
|
if (logger) {
|
|
191
|
-
logger.warn(`
|
|
257
|
+
logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
|
|
192
258
|
}
|
|
259
|
+
resourceCache.set(url, null);
|
|
193
260
|
return null
|
|
194
261
|
}
|
|
195
262
|
|
|
@@ -335,15 +402,11 @@ async function calculateIntegrity(
|
|
|
335
402
|
const {
|
|
336
403
|
ignoreMissingAsset,
|
|
337
404
|
bypassDomains,
|
|
405
|
+
trustDomains,
|
|
338
406
|
hashAlgorithm,
|
|
339
407
|
hashedAssets
|
|
340
408
|
} = options;
|
|
341
409
|
|
|
342
|
-
// Skip specified domains
|
|
343
|
-
if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
|
|
344
|
-
return null
|
|
345
|
-
}
|
|
346
|
-
|
|
347
410
|
// With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
|
|
348
411
|
// own build output. Those must be hashed from the bundle, not fetched - the
|
|
349
412
|
// CDN may not have been deployed yet, and this is the very case SRI exists
|
|
@@ -352,12 +415,22 @@ async function calculateIntegrity(
|
|
|
352
415
|
const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
|
|
353
416
|
const fetchUrl = ownAsset ? null : externalUrl(url);
|
|
354
417
|
|
|
418
|
+
// Both domain options match the URL that would actually be fetched.
|
|
419
|
+
// `matchesDomain` needs a scheme, so matching the raw `url` silently missed
|
|
420
|
+
// every protocol-relative `//host/path` - and `trustDomains` below, which
|
|
421
|
+
// already saw the normalized form, would then disagree with `bypassDomains`
|
|
422
|
+
// about the same tag.
|
|
423
|
+
if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
355
427
|
let source;
|
|
356
428
|
let bundleFileName = null;
|
|
357
429
|
if (fetchUrl) {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
430
|
+
const trusted = matchesDomain(fetchUrl, trustDomains, logger);
|
|
431
|
+
source = await fetchVerifiedResource(
|
|
432
|
+
fetchUrl, cacheManager.getResourceCache(), logger, trusted
|
|
433
|
+
);
|
|
361
434
|
if (!source) return null
|
|
362
435
|
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
363
436
|
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
@@ -848,6 +921,7 @@ function sri(options = {}) {
|
|
|
848
921
|
const {
|
|
849
922
|
ignoreMissingAsset = false,
|
|
850
923
|
bypassDomains = [],
|
|
924
|
+
trustDomains = [],
|
|
851
925
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
852
926
|
crossorigin = 'anonymous',
|
|
853
927
|
logLevel = 'warn',
|
|
@@ -910,6 +984,7 @@ function sri(options = {}) {
|
|
|
910
984
|
transformer = createTransformer({
|
|
911
985
|
ignoreMissingAsset,
|
|
912
986
|
bypassDomains,
|
|
987
|
+
trustDomains,
|
|
913
988
|
hashAlgorithm,
|
|
914
989
|
crossorigin,
|
|
915
990
|
hashedAssets
|
package/package.json
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -19,12 +19,24 @@ export interface SriOptions {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Hostnames to leave untouched. Matches the host itself and its subdomains.
|
|
22
|
-
* Only applies to external
|
|
23
|
-
* on a tag to opt a single element out.
|
|
22
|
+
* Only applies to external URLs, protocol-relative `//host/path` included;
|
|
23
|
+
* use the `skip-sri` attribute on a tag to opt a single element out.
|
|
24
24
|
* @default []
|
|
25
25
|
*/
|
|
26
26
|
bypassDomains?: string[]
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Hostnames whose bytes you vouch for. An external resource is normally only
|
|
30
|
+
* hashed when its origin declares the URL immutable (`Cache-Control:
|
|
31
|
+
* immutable`, or a max-age of a year or more); a host listed here is hashed
|
|
32
|
+
* regardless. Matches the host itself and its subdomains.
|
|
33
|
+
*
|
|
34
|
+
* Use it for a stable host that does not set the header - not to force SRI
|
|
35
|
+
* onto a vendor's rolling URL, which will break on their next deploy.
|
|
36
|
+
* @default []
|
|
37
|
+
*/
|
|
38
|
+
trustDomains?: string[]
|
|
39
|
+
|
|
28
40
|
/**
|
|
29
41
|
* Warn instead of failing the build when an asset resolves to neither a
|
|
30
42
|
* bundle entry nor a file in `publicDir`.
|