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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # vite-plugin-sri4
2
2
 
3
- ![NPM Version](https://img.shields.io/npm/v/vite-plugin-sri4)
3
+ [![NPM Version](https://img.shields.io/npm/v/vite-plugin-sri4)](https://www.npmjs.com/package/vite-plugin-sri4)
4
4
  [![codecov](https://codecov.io/gh/7a6163/vite-plugin-sri4/graph/badge.svg?token=GOVB4J3D19)](https://codecov.io/gh/7a6163/vite-plugin-sri4)
5
5
  ![License](https://img.shields.io/npm/l/vite-plugin-sri4)
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
- - **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,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. **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
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. **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
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
- 2. **CORS Errors**
232
- - Ensure the resource supports CORS
233
- - Add the domain to `bypassDomains` if needed
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
- 3. **Build Performance**
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
- * Check if URL is from a bypass domain
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 isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
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 bypassDomains.some(domain =>
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
- * Resource check with retry mechanism
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
- async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
97
- if (urlSupportCache.has(url)) {
98
- return urlSupportCache.get(url)
99
- }
100
-
101
- let lastError;
102
- for (let attempt = 0; attempt <= retries; attempt++) {
103
- try {
104
- const controller = new AbortController();
105
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
106
-
107
- const response = await fetch(url, {
108
- method: 'HEAD',
109
- signal: controller.signal
110
- });
111
-
112
- clearTimeout(timeoutId);
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
- // Only `*` can be verified at build time. Injecting integrity also means
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
- // Don't wait after the last failed attempt
141
- if (attempt < retries) {
142
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
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
- if (logger) {
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
- * Optimized resource fetching function with retry mechanism and caching
160
+ * Resource check with retry mechanism
156
161
  */
157
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
158
- // Check cache
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
- throw new Error(`HTTP error! status: ${response.status}`)
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
- if (logger) {
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(`Failed to fetch external resource: ${url}`, lastError);
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 isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
363
- if (!isSupported) return null
364
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
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
- * Check if URL is from a bypass domain
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 isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
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 bypassDomains.some(domain =>
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
- * Resource check with retry mechanism
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
- async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
93
- if (urlSupportCache.has(url)) {
94
- return urlSupportCache.get(url)
95
- }
96
-
97
- let lastError;
98
- for (let attempt = 0; attempt <= retries; attempt++) {
99
- try {
100
- const controller = new AbortController();
101
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
102
-
103
- const response = await fetch(url, {
104
- method: 'HEAD',
105
- signal: controller.signal
106
- });
107
-
108
- clearTimeout(timeoutId);
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
- // Only `*` can be verified at build time. Injecting integrity also means
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
- // Don't wait after the last failed attempt
137
- if (attempt < retries) {
138
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
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
- if (logger) {
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
- * Optimized resource fetching function with retry mechanism and caching
156
+ * Resource check with retry mechanism
152
157
  */
153
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
154
- // Check cache
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
- throw new Error(`HTTP error! status: ${response.status}`)
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
- if (logger) {
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(`Failed to fetch external resource: ${url}`, lastError);
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 isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
359
- if (!isSupported) return null
360
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "4.2.0",
3
+ "version": "5.1.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`.