vite-plugin-sri4 5.0.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
 
@@ -180,13 +180,28 @@ A blacklist would have to catch every one of the bottom rows individually, and t
180
180
 
181
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
182
 
183
- **Or vouch for the host** when you know it is stable and it just does not say so:
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:
184
193
 
185
194
  ```js
186
- sri({ trustDomains: ['assets.internal.example'] })
195
+ sri({ trustDomains: ['js.tappaysdk.com'] })
187
196
  ```
188
197
 
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.
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`.
190
205
 
191
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:
192
207
 
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
  }
@@ -165,35 +159,48 @@ function isImmutableResponse(cacheControl) {
165
159
  /**
166
160
  * Resource check with retry mechanism
167
161
  */
168
- async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
169
- if (urlSupportCache.has(url)) {
170
- return urlSupportCache.get(url)
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) {
179
+ if (resourceCache.has(url)) {
180
+ return resourceCache.get(url)
171
181
  }
172
182
 
183
+ const reject = (message) => {
184
+ if (logger && message) logger.warn(message);
185
+ resourceCache.set(url, null);
186
+ return null
187
+ };
188
+
173
189
  let lastError;
174
190
  for (let attempt = 0; attempt <= retries; attempt++) {
175
191
  try {
176
192
  const controller = new AbortController();
177
193
  const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
178
194
 
179
- const response = await fetch(url, {
180
- method: 'HEAD',
181
- signal: controller.signal
182
- });
195
+ const response = await fetch(url, { signal: controller.signal });
183
196
 
184
197
  clearTimeout(timeoutId);
185
198
 
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
199
  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
200
+ return reject(
201
+ `Skipping SRI for ${url}: the server answered ${response.status}. ` +
202
+ 'Add the domain to bypassDomains to silence this.'
203
+ )
197
204
  }
198
205
 
199
206
  // Only `*` can be verified at build time. Injecting integrity also means
@@ -202,16 +209,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
202
209
  // served from, that turns a working script into a blocked one.
203
210
  const corsHeader = response.headers.get('access-control-allow-origin');
204
211
  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
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
+ )
215
218
  }
216
219
 
217
220
  // Reachable and CORS-eligible is not the same property as byte-stable.
@@ -228,65 +231,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
228
231
  // so a vary-based gate would let that resource straight through.
229
232
  const cacheControl = response.headers.get('cache-control');
230
233
  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
243
- }
244
-
245
- urlSupportCache.set(url, true);
246
- return true
247
- } catch (error) {
248
- lastError = error;
249
- if (error.name === 'AbortError') {
250
- if (logger) {
251
- logger.warn(`Resource check timed out: ${url}`);
252
- }
253
- break // Don't retry timeouts
254
- }
255
-
256
- // Don't wait after the last failed attempt
257
- if (attempt < retries) {
258
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
259
- }
260
- }
261
- }
262
-
263
- if (logger) {
264
- logger.warn(`Failed to check resource support: ${url}`, lastError);
265
- }
266
- urlSupportCache.set(url, false);
267
- return false
268
- }
269
-
270
- /**
271
- * Optimized resource fetching function with retry mechanism and caching
272
- */
273
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
274
- // Check cache
275
- if (resourceCache.has(url)) {
276
- return resourceCache.get(url)
277
- }
278
-
279
- let lastError;
280
- for (let attempt = 0; attempt <= retries; attempt++) {
281
- try {
282
- const controller = new AbortController();
283
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
284
-
285
- const response = await fetch(url, { signal: controller.signal });
286
- clearTimeout(timeoutId);
287
-
288
- if (!response.ok) {
289
- throw new Error(`HTTP error! status: ${response.status}`)
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
+ )
290
242
  }
291
243
 
292
244
  const data = new Uint8Array(await response.arrayBuffer());
@@ -295,12 +247,10 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
295
247
  } catch (error) {
296
248
  lastError = error;
297
249
  if (error.name === 'AbortError') {
298
- if (logger) {
299
- logger.warn(`Resource fetch timed out: ${url}`);
300
- }
301
- break // Don't retry timeouts
250
+ return reject(`Skipping SRI for ${url}: the request timed out.`)
302
251
  }
303
252
 
253
+ // Don't wait after the last failed attempt
304
254
  if (attempt < retries) {
305
255
  await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
306
256
  }
@@ -308,8 +258,9 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
308
258
  }
309
259
 
310
260
  if (logger) {
311
- logger.warn(`Failed to fetch external resource: ${url}`, lastError);
261
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
312
262
  }
263
+ resourceCache.set(url, null);
313
264
  return null
314
265
  }
315
266
 
@@ -481,11 +432,9 @@ async function calculateIntegrity(
481
432
  let bundleFileName = null;
482
433
  if (fetchUrl) {
483
434
  const trusted = matchesDomain(fetchUrl, trustDomains, logger);
484
- const isSupported = await checkResourceSupport(
485
- fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
435
+ source = await fetchVerifiedResource(
436
+ fetchUrl, cacheManager.getResourceCache(), logger, trusted
486
437
  );
487
- if (!isSupported) return null
488
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
489
438
  if (!source) return null
490
439
  } else if (!ownAsset && SCHEME_RE.test(url)) {
491
440
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
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
  }
@@ -161,35 +155,48 @@ function isImmutableResponse(cacheControl) {
161
155
  /**
162
156
  * Resource check with retry mechanism
163
157
  */
164
- async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
165
- if (urlSupportCache.has(url)) {
166
- return urlSupportCache.get(url)
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) {
175
+ if (resourceCache.has(url)) {
176
+ return resourceCache.get(url)
167
177
  }
168
178
 
179
+ const reject = (message) => {
180
+ if (logger && message) logger.warn(message);
181
+ resourceCache.set(url, null);
182
+ return null
183
+ };
184
+
169
185
  let lastError;
170
186
  for (let attempt = 0; attempt <= retries; attempt++) {
171
187
  try {
172
188
  const controller = new AbortController();
173
189
  const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
174
190
 
175
- const response = await fetch(url, {
176
- method: 'HEAD',
177
- signal: controller.signal
178
- });
191
+ const response = await fetch(url, { signal: controller.signal });
179
192
 
180
193
  clearTimeout(timeoutId);
181
194
 
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
195
  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
196
+ return reject(
197
+ `Skipping SRI for ${url}: the server answered ${response.status}. ` +
198
+ 'Add the domain to bypassDomains to silence this.'
199
+ )
193
200
  }
194
201
 
195
202
  // Only `*` can be verified at build time. Injecting integrity also means
@@ -198,16 +205,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
198
205
  // served from, that turns a working script into a blocked one.
199
206
  const corsHeader = response.headers.get('access-control-allow-origin');
200
207
  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
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
+ )
211
214
  }
212
215
 
213
216
  // Reachable and CORS-eligible is not the same property as byte-stable.
@@ -224,65 +227,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
224
227
  // so a vary-based gate would let that resource straight through.
225
228
  const cacheControl = response.headers.get('cache-control');
226
229
  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
239
- }
240
-
241
- urlSupportCache.set(url, true);
242
- return true
243
- } catch (error) {
244
- lastError = error;
245
- if (error.name === 'AbortError') {
246
- if (logger) {
247
- logger.warn(`Resource check timed out: ${url}`);
248
- }
249
- break // Don't retry timeouts
250
- }
251
-
252
- // Don't wait after the last failed attempt
253
- if (attempt < retries) {
254
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
255
- }
256
- }
257
- }
258
-
259
- if (logger) {
260
- logger.warn(`Failed to check resource support: ${url}`, lastError);
261
- }
262
- urlSupportCache.set(url, false);
263
- return false
264
- }
265
-
266
- /**
267
- * Optimized resource fetching function with retry mechanism and caching
268
- */
269
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
270
- // Check cache
271
- if (resourceCache.has(url)) {
272
- return resourceCache.get(url)
273
- }
274
-
275
- let lastError;
276
- for (let attempt = 0; attempt <= retries; attempt++) {
277
- try {
278
- const controller = new AbortController();
279
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
280
-
281
- const response = await fetch(url, { signal: controller.signal });
282
- clearTimeout(timeoutId);
283
-
284
- if (!response.ok) {
285
- throw new Error(`HTTP error! status: ${response.status}`)
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
+ )
286
238
  }
287
239
 
288
240
  const data = new Uint8Array(await response.arrayBuffer());
@@ -291,12 +243,10 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
291
243
  } catch (error) {
292
244
  lastError = error;
293
245
  if (error.name === 'AbortError') {
294
- if (logger) {
295
- logger.warn(`Resource fetch timed out: ${url}`);
296
- }
297
- break // Don't retry timeouts
246
+ return reject(`Skipping SRI for ${url}: the request timed out.`)
298
247
  }
299
248
 
249
+ // Don't wait after the last failed attempt
300
250
  if (attempt < retries) {
301
251
  await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
302
252
  }
@@ -304,8 +254,9 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
304
254
  }
305
255
 
306
256
  if (logger) {
307
- logger.warn(`Failed to fetch external resource: ${url}`, lastError);
257
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
308
258
  }
259
+ resourceCache.set(url, null);
309
260
  return null
310
261
  }
311
262
 
@@ -477,11 +428,9 @@ async function calculateIntegrity(
477
428
  let bundleFileName = null;
478
429
  if (fetchUrl) {
479
430
  const trusted = matchesDomain(fetchUrl, trustDomains, logger);
480
- const isSupported = await checkResourceSupport(
481
- fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
431
+ source = await fetchVerifiedResource(
432
+ fetchUrl, cacheManager.getResourceCache(), logger, trusted
482
433
  );
483
- if (!isSupported) return null
484
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
485
434
  if (!source) return null
486
435
  } else if (!ownAsset && SCHEME_RE.test(url)) {
487
436
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "5.0.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",