vite-plugin-sri4 4.2.0 → 5.0.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 CHANGED
@@ -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
- - **CORS Support Check:** For external resources, a CORS check is performed to verify access via `Access-Control-Allow-Origin`.
30
- - **Bypass Domains:** Option to specify domains to bypass SRI injection, plus a `skip-sri` attribute to opt out a single tag.
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
- Array of domain names where SRI injection should be skipped. This allows external resources from specified domains to be excluded from SRI checks (for example, when they may not support CORS).
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,62 @@ 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:
184
+
185
+ ```js
186
+ sri({ trustDomains: ['assets.internal.example'] })
187
+ ```
188
+
189
+ Do not point `trustDomains` at a vendor's rolling URL. Stripe, for one, documents that `js.stripe.com/v3/` must not be pinned; forcing a hash onto it produces a page that works until their next deploy.
190
+
191
+ **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:
192
+
193
+ ```js
194
+ sri({ bypassDomains: ['www.googletagmanager.com', 'connect.facebook.net'] })
195
+ ```
196
+
138
197
  ## When SRI Actually Helps
139
198
 
140
199
  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 +264,11 @@ The example project shows:
205
264
  - Consider `sha512` for maximum security
206
265
  - Avoid `sha1` as it's considered cryptographically weak
207
266
 
208
- 2. **CORS Configuration**
209
- - Ensure your CDN or hosting service supports CORS
210
- - Set appropriate `Access-Control-Allow-Origin` headers
211
- - Use `bypassDomains` for trusted domains that don't support CORS
267
+ 2. **External Resources**
268
+ - 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
269
+ - Serve your own assets with `Access-Control-Allow-Origin: *` and an immutable `Cache-Control`
270
+ - Use `bypassDomains` for a host you have decided not to protect — a vendor's auto-updating widget or analytics script
271
+ - Use `trustDomains` only for a host you control that is stable but does not say so in its headers
212
272
 
213
273
  3. **Performance Optimization**
214
274
  - Enable `ignoreMissingAsset` in development for faster builds
@@ -223,17 +283,21 @@ The example project shows:
223
283
 
224
284
  ### Common Issues
225
285
 
226
- 1. **Missing Integrity Attributes**
227
- - Check if the file is in your build output
228
- - Verify the file path is correct
229
- - Enable debug mode to see detailed logs
286
+ 1. **An external resource has no integrity**
287
+ - Read the build log. Every skip warns and names its reason
288
+ - `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)
289
+ - `Access-Control-Allow-Origin is absent` / `not "*"` — nothing to do at build time; `bypassDomains` silences it
290
+
291
+ 2. **A local asset has no integrity**
292
+ - Check the file is in your build output, or in `publicDir`
293
+ - Verify the path in the tag matches, including `base`
294
+ - Enable debug mode to see per-resource decisions
230
295
 
231
- 2. **CORS Errors**
232
- - Ensure the resource supports CORS
233
- - Add the domain to `bypassDomains` if needed
234
- - Check network tab for CORS headers
296
+ 3. **The browser blocks a resource that has integrity**
297
+ - 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
298
+ - If it is external, the URL is not as immutable as its headers claim. Move it to `bypassDomains`
235
299
 
236
- 3. **Build Performance**
300
+ 4. **Build Performance**
237
301
  - Use `ignoreMissingAsset` if you have many external resources
238
302
  - Disable debug mode in production
239
303
  - Consider using a CDN for external resources
package/dist/index.cjs CHANGED
@@ -72,14 +72,16 @@ class CacheManager {
72
72
  const DEFAULT_TIMEOUT = 5000;
73
73
 
74
74
  /**
75
- * Check if URL is from a bypass domain
75
+ * Does an external URL's host match one of `domains`, or a subdomain of one?
76
+ * Used by both `bypassDomains` and `trustDomains`.
76
77
  */
77
- function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
78
+ function matchesDomain(url, domains = [], logger = null) {
78
79
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
80
+ if (domains.length === 0) return false
79
81
 
80
82
  try {
81
83
  const urlObj = new URL(url);
82
- return bypassDomains.some(domain =>
84
+ return domains.some(domain =>
83
85
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
84
86
  )
85
87
  } catch (error) {
@@ -90,10 +92,80 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
90
92
  }
91
93
  }
92
94
 
95
+ // A year. The conventional encoding of "this URL's bytes will never change",
96
+ // and what every CDN puts on a version-pinned path.
97
+ const IMMUTABLE_MAX_AGE = 31536000;
98
+
99
+ /**
100
+ * Does the origin declare this URL's bytes immutable - `Cache-Control:
101
+ * immutable`, or a max-age of a year or more - and nothing in the same header
102
+ * contradicting it?
103
+ *
104
+ * Measured, because the split is what makes this usable as a gate. Pinned
105
+ * third-party libraries, the case SRI actually exists for:
106
+ *
107
+ * cdnjs jquery/3.7.1 max-age=30672000, immutable
108
+ * jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
109
+ * unpkg htmx.org@1.9.12 max-age=31536000
110
+ * code.jquery.com 3.7.1 max-age=31536000
111
+ *
112
+ * Everything that rolls under a stable URL:
113
+ *
114
+ * jsdelivr vue@3 max-age=604800
115
+ * fonts.googleapis.com max-age=86400 (also `private`)
116
+ * plausible.io/js/script.js max-age=86400
117
+ * cdn.tailwindcss.com max-age=14400
118
+ * connect.facebook.net max-age=1200
119
+ * js.stripe.com/v3/ max-age=120
120
+ * unpkg react@18 max-age=60
121
+ *
122
+ * Nothing lands between 604800 and 30672000, so the threshold is not a
123
+ * balancing act - it separates two clusters the CDNs themselves created.
124
+ */
125
+ function isImmutableResponse(cacheControl) {
126
+ if (!cacheControl) return false
127
+
128
+ let immutable = false;
129
+
130
+ for (const directive of cacheControl.split(',')) {
131
+ // Token equality, not substring: `x-immutable` is not this directive
132
+ const token = directive.trim().toLowerCase();
133
+
134
+ // These veto whatever else the header claims, and are checked against the
135
+ // whole header rather than returning early, because freshness and
136
+ // shareability are orthogonal - a per-client response can carry a long
137
+ // max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
138
+ // it, but revalidate every time", i.e. the bytes may have changed.
139
+ //
140
+ // The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
141
+ // only scope the directive to those headers, so vetoing on them is
142
+ // stricter than the spec requires. That is the right way to be wrong here:
143
+ // the cost is losing SRI on a resource that would have been fine, and it
144
+ // is logged. Not vetoing costs a page that only breaks in the browser.
145
+ if (
146
+ token === 'no-store' ||
147
+ token === 'private' || token.startsWith('private=') ||
148
+ token === 'no-cache' || token.startsWith('no-cache=')
149
+ ) {
150
+ return false
151
+ }
152
+
153
+ if (token === 'immutable') immutable = true;
154
+
155
+ if (token.startsWith('max-age=')) {
156
+ // RFC 9111 permits a quoted-string value: `max-age="31536000"`
157
+ const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
158
+ if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
159
+ }
160
+ }
161
+
162
+ return immutable
163
+ }
164
+
93
165
  /**
94
166
  * Resource check with retry mechanism
95
167
  */
96
- async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
168
+ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
97
169
  if (urlSupportCache.has(url)) {
98
170
  return urlSupportCache.get(url)
99
171
  }
@@ -111,23 +183,67 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
111
183
 
112
184
  clearTimeout(timeoutId);
113
185
 
186
+ // Every path out of here that skips a resource says why. A tag that
187
+ // silently ships without integrity is the thing that is easy to miss.
188
+ if (!response.ok) {
189
+ if (logger) {
190
+ logger.warn(
191
+ `Skipping SRI for ${url}: HEAD returned ${response.status}, so the resource ` +
192
+ 'could not be checked. Add the domain to bypassDomains to silence this.'
193
+ );
194
+ }
195
+ urlSupportCache.set(url, false);
196
+ return false
197
+ }
198
+
114
199
  // Only `*` can be verified at build time. Injecting integrity also means
115
200
  // injecting crossorigin="anonymous"; if the server answers with a
116
201
  // 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.
202
+ // served from, that turns a working script into a blocked one.
120
203
  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
- );
204
+ if (corsHeader !== '*') {
205
+ if (logger) {
206
+ logger.warn(
207
+ `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
208
+ `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
209
+ 'cannot be verified at build time. ' +
210
+ 'Add the domain to bypassDomains to silence this.'
211
+ );
212
+ }
213
+ urlSupportCache.set(url, false);
214
+ return false
215
+ }
216
+
217
+ // Reachable and CORS-eligible is not the same property as byte-stable.
218
+ // A hash pins one snapshot of bytes forever, so it is only safe on a URL
219
+ // whose bytes never change - and the origin is the only party that knows.
220
+ // Require it to say so rather than hunting for reasons to skip: a
221
+ // blacklist of known-bad origins is never finished (Google Fonts is
222
+ // `private`, but cdn.tailwindcss.com and plausible.io are ordinary
223
+ // `public` responses that roll just the same), and every gap in it ships
224
+ // a build that works today and breaks whenever the vendor deploys.
225
+ //
226
+ // Deliberately not `vary`: Google Fonts varies on User-Agent without
227
+ // declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
228
+ // so a vary-based gate would let that resource straight through.
229
+ const cacheControl = response.headers.get('cache-control');
230
+ if (!trusted && !isImmutableResponse(cacheControl)) {
231
+ if (logger) {
232
+ logger.warn(
233
+ `Skipping SRI for ${url}: Cache-Control is ` +
234
+ `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
235
+ 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
236
+ 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
237
+ 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
238
+ "vendor's rolling URL ships a page that breaks on their next deploy."
239
+ );
240
+ }
241
+ urlSupportCache.set(url, false);
242
+ return false
128
243
  }
129
- urlSupportCache.set(url, isSupported);
130
- return isSupported
244
+
245
+ urlSupportCache.set(url, true);
246
+ return true
131
247
  } catch (error) {
132
248
  lastError = error;
133
249
  if (error.name === 'AbortError') {
@@ -339,15 +455,11 @@ async function calculateIntegrity(
339
455
  const {
340
456
  ignoreMissingAsset,
341
457
  bypassDomains,
458
+ trustDomains,
342
459
  hashAlgorithm,
343
460
  hashedAssets
344
461
  } = options;
345
462
 
346
- // Skip specified domains
347
- if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
348
- return null
349
- }
350
-
351
463
  // With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
352
464
  // own build output. Those must be hashed from the bundle, not fetched - the
353
465
  // CDN may not have been deployed yet, and this is the very case SRI exists
@@ -356,10 +468,22 @@ async function calculateIntegrity(
356
468
  const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
357
469
  const fetchUrl = ownAsset ? null : externalUrl(url);
358
470
 
471
+ // Both domain options match the URL that would actually be fetched.
472
+ // `matchesDomain` needs a scheme, so matching the raw `url` silently missed
473
+ // every protocol-relative `//host/path` - and `trustDomains` below, which
474
+ // already saw the normalized form, would then disagree with `bypassDomains`
475
+ // about the same tag.
476
+ if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
477
+ return null
478
+ }
479
+
359
480
  let source;
360
481
  let bundleFileName = null;
361
482
  if (fetchUrl) {
362
- const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
483
+ const trusted = matchesDomain(fetchUrl, trustDomains, logger);
484
+ const isSupported = await checkResourceSupport(
485
+ fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
486
+ );
363
487
  if (!isSupported) return null
364
488
  source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
365
489
  if (!source) return null
@@ -852,6 +976,7 @@ function sri(options = {}) {
852
976
  const {
853
977
  ignoreMissingAsset = false,
854
978
  bypassDomains = [],
979
+ trustDomains = [],
855
980
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
856
981
  crossorigin = 'anonymous',
857
982
  logLevel = 'warn',
@@ -914,6 +1039,7 @@ function sri(options = {}) {
914
1039
  transformer = createTransformer({
915
1040
  ignoreMissingAsset,
916
1041
  bypassDomains,
1042
+ trustDomains,
917
1043
  hashAlgorithm,
918
1044
  crossorigin,
919
1045
  hashedAssets
package/dist/index.js CHANGED
@@ -68,14 +68,16 @@ class CacheManager {
68
68
  const DEFAULT_TIMEOUT = 5000;
69
69
 
70
70
  /**
71
- * Check if URL is from a bypass domain
71
+ * Does an external URL's host match one of `domains`, or a subdomain of one?
72
+ * Used by both `bypassDomains` and `trustDomains`.
72
73
  */
73
- function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
74
+ function matchesDomain(url, domains = [], logger = null) {
74
75
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
76
+ if (domains.length === 0) return false
75
77
 
76
78
  try {
77
79
  const urlObj = new URL(url);
78
- return bypassDomains.some(domain =>
80
+ return domains.some(domain =>
79
81
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
80
82
  )
81
83
  } catch (error) {
@@ -86,10 +88,80 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
86
88
  }
87
89
  }
88
90
 
91
+ // A year. The conventional encoding of "this URL's bytes will never change",
92
+ // and what every CDN puts on a version-pinned path.
93
+ const IMMUTABLE_MAX_AGE = 31536000;
94
+
95
+ /**
96
+ * Does the origin declare this URL's bytes immutable - `Cache-Control:
97
+ * immutable`, or a max-age of a year or more - and nothing in the same header
98
+ * contradicting it?
99
+ *
100
+ * Measured, because the split is what makes this usable as a gate. Pinned
101
+ * third-party libraries, the case SRI actually exists for:
102
+ *
103
+ * cdnjs jquery/3.7.1 max-age=30672000, immutable
104
+ * jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
105
+ * unpkg htmx.org@1.9.12 max-age=31536000
106
+ * code.jquery.com 3.7.1 max-age=31536000
107
+ *
108
+ * Everything that rolls under a stable URL:
109
+ *
110
+ * jsdelivr vue@3 max-age=604800
111
+ * fonts.googleapis.com max-age=86400 (also `private`)
112
+ * plausible.io/js/script.js max-age=86400
113
+ * cdn.tailwindcss.com max-age=14400
114
+ * connect.facebook.net max-age=1200
115
+ * js.stripe.com/v3/ max-age=120
116
+ * unpkg react@18 max-age=60
117
+ *
118
+ * Nothing lands between 604800 and 30672000, so the threshold is not a
119
+ * balancing act - it separates two clusters the CDNs themselves created.
120
+ */
121
+ function isImmutableResponse(cacheControl) {
122
+ if (!cacheControl) return false
123
+
124
+ let immutable = false;
125
+
126
+ for (const directive of cacheControl.split(',')) {
127
+ // Token equality, not substring: `x-immutable` is not this directive
128
+ const token = directive.trim().toLowerCase();
129
+
130
+ // These veto whatever else the header claims, and are checked against the
131
+ // whole header rather than returning early, because freshness and
132
+ // shareability are orthogonal - a per-client response can carry a long
133
+ // max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
134
+ // it, but revalidate every time", i.e. the bytes may have changed.
135
+ //
136
+ // The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
137
+ // only scope the directive to those headers, so vetoing on them is
138
+ // stricter than the spec requires. That is the right way to be wrong here:
139
+ // the cost is losing SRI on a resource that would have been fine, and it
140
+ // is logged. Not vetoing costs a page that only breaks in the browser.
141
+ if (
142
+ token === 'no-store' ||
143
+ token === 'private' || token.startsWith('private=') ||
144
+ token === 'no-cache' || token.startsWith('no-cache=')
145
+ ) {
146
+ return false
147
+ }
148
+
149
+ if (token === 'immutable') immutable = true;
150
+
151
+ if (token.startsWith('max-age=')) {
152
+ // RFC 9111 permits a quoted-string value: `max-age="31536000"`
153
+ const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
154
+ if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
155
+ }
156
+ }
157
+
158
+ return immutable
159
+ }
160
+
89
161
  /**
90
162
  * Resource check with retry mechanism
91
163
  */
92
- async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
164
+ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
93
165
  if (urlSupportCache.has(url)) {
94
166
  return urlSupportCache.get(url)
95
167
  }
@@ -107,23 +179,67 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
107
179
 
108
180
  clearTimeout(timeoutId);
109
181
 
182
+ // Every path out of here that skips a resource says why. A tag that
183
+ // silently ships without integrity is the thing that is easy to miss.
184
+ if (!response.ok) {
185
+ if (logger) {
186
+ logger.warn(
187
+ `Skipping SRI for ${url}: HEAD returned ${response.status}, so the resource ` +
188
+ 'could not be checked. Add the domain to bypassDomains to silence this.'
189
+ );
190
+ }
191
+ urlSupportCache.set(url, false);
192
+ return false
193
+ }
194
+
110
195
  // Only `*` can be verified at build time. Injecting integrity also means
111
196
  // injecting crossorigin="anonymous"; if the server answers with a
112
197
  // 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.
198
+ // served from, that turns a working script into a blocked one.
116
199
  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
- );
200
+ if (corsHeader !== '*') {
201
+ if (logger) {
202
+ logger.warn(
203
+ `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
204
+ `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
205
+ 'cannot be verified at build time. ' +
206
+ 'Add the domain to bypassDomains to silence this.'
207
+ );
208
+ }
209
+ urlSupportCache.set(url, false);
210
+ return false
211
+ }
212
+
213
+ // Reachable and CORS-eligible is not the same property as byte-stable.
214
+ // A hash pins one snapshot of bytes forever, so it is only safe on a URL
215
+ // whose bytes never change - and the origin is the only party that knows.
216
+ // Require it to say so rather than hunting for reasons to skip: a
217
+ // blacklist of known-bad origins is never finished (Google Fonts is
218
+ // `private`, but cdn.tailwindcss.com and plausible.io are ordinary
219
+ // `public` responses that roll just the same), and every gap in it ships
220
+ // a build that works today and breaks whenever the vendor deploys.
221
+ //
222
+ // Deliberately not `vary`: Google Fonts varies on User-Agent without
223
+ // declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
224
+ // so a vary-based gate would let that resource straight through.
225
+ const cacheControl = response.headers.get('cache-control');
226
+ if (!trusted && !isImmutableResponse(cacheControl)) {
227
+ if (logger) {
228
+ logger.warn(
229
+ `Skipping SRI for ${url}: Cache-Control is ` +
230
+ `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
231
+ 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
232
+ 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
233
+ 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
234
+ "vendor's rolling URL ships a page that breaks on their next deploy."
235
+ );
236
+ }
237
+ urlSupportCache.set(url, false);
238
+ return false
124
239
  }
125
- urlSupportCache.set(url, isSupported);
126
- return isSupported
240
+
241
+ urlSupportCache.set(url, true);
242
+ return true
127
243
  } catch (error) {
128
244
  lastError = error;
129
245
  if (error.name === 'AbortError') {
@@ -335,15 +451,11 @@ async function calculateIntegrity(
335
451
  const {
336
452
  ignoreMissingAsset,
337
453
  bypassDomains,
454
+ trustDomains,
338
455
  hashAlgorithm,
339
456
  hashedAssets
340
457
  } = options;
341
458
 
342
- // Skip specified domains
343
- if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
344
- return null
345
- }
346
-
347
459
  // With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
348
460
  // own build output. Those must be hashed from the bundle, not fetched - the
349
461
  // CDN may not have been deployed yet, and this is the very case SRI exists
@@ -352,10 +464,22 @@ async function calculateIntegrity(
352
464
  const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
353
465
  const fetchUrl = ownAsset ? null : externalUrl(url);
354
466
 
467
+ // Both domain options match the URL that would actually be fetched.
468
+ // `matchesDomain` needs a scheme, so matching the raw `url` silently missed
469
+ // every protocol-relative `//host/path` - and `trustDomains` below, which
470
+ // already saw the normalized form, would then disagree with `bypassDomains`
471
+ // about the same tag.
472
+ if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
473
+ return null
474
+ }
475
+
355
476
  let source;
356
477
  let bundleFileName = null;
357
478
  if (fetchUrl) {
358
- const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
479
+ const trusted = matchesDomain(fetchUrl, trustDomains, logger);
480
+ const isSupported = await checkResourceSupport(
481
+ fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
482
+ );
359
483
  if (!isSupported) return null
360
484
  source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
361
485
  if (!source) return null
@@ -848,6 +972,7 @@ function sri(options = {}) {
848
972
  const {
849
973
  ignoreMissingAsset = false,
850
974
  bypassDomains = [],
975
+ trustDomains = [],
851
976
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
852
977
  crossorigin = 'anonymous',
853
978
  logLevel = 'warn',
@@ -910,6 +1035,7 @@ function sri(options = {}) {
910
1035
  transformer = createTransformer({
911
1036
  ignoreMissingAsset,
912
1037
  bypassDomains,
1038
+ trustDomains,
913
1039
  hashAlgorithm,
914
1040
  crossorigin,
915
1041
  hashedAssets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "4.2.0",
3
+ "version": "5.0.0",
4
4
  "description": "A Vite plugin to generate Subresource Integrity (SRI) hashes for output files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
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 (http/https) URLs; use the `skip-sri` attribute
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`.