vite-plugin-sri4 3.1.0 → 4.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 +61 -11
- package/dist/index.cjs +415 -139
- package/dist/index.js +415 -139
- package/package.json +10 -11
package/dist/index.cjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
|
-
var
|
|
6
|
-
var path = require('path');
|
|
5
|
+
var node_crypto = require('node:crypto');
|
|
6
|
+
var path = require('node:path');
|
|
7
7
|
var fetch = require('cross-fetch');
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -109,8 +109,21 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
|
|
|
109
109
|
|
|
110
110
|
clearTimeout(timeoutId);
|
|
111
111
|
|
|
112
|
+
// Only `*` can be verified at build time. Injecting integrity also means
|
|
113
|
+
// injecting crossorigin="anonymous"; if the server answers with a
|
|
114
|
+
// concrete origin that does not match wherever the HTML ends up being
|
|
115
|
+
// served from, that turns a working script into a blocked one. Skipping
|
|
116
|
+
// is the safe outcome, but say so at warn level - silence here is what
|
|
117
|
+
// makes an unprotected resource easy to miss.
|
|
112
118
|
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
113
|
-
const isSupported = response.ok &&
|
|
119
|
+
const isSupported = response.ok && corsHeader === '*';
|
|
120
|
+
if (response.ok && corsHeader && corsHeader !== '*' && logger) {
|
|
121
|
+
logger.warn(
|
|
122
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
|
|
123
|
+
'so crossorigin="anonymous" cannot be verified at build time. ' +
|
|
124
|
+
'Add the domain to bypassDomains to silence this.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
114
127
|
urlSupportCache.set(url, isSupported);
|
|
115
128
|
return isSupported
|
|
116
129
|
} catch (error) {
|
|
@@ -182,43 +195,104 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
182
195
|
return null
|
|
183
196
|
}
|
|
184
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Read the hashable source out of a bundle entry (chunk code or asset source)
|
|
200
|
+
*/
|
|
201
|
+
function bundleSource(item) {
|
|
202
|
+
return item.type === 'chunk' ? item.code : item.source
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Compute an SRI string for a source that may be a string, Buffer or Uint8Array
|
|
207
|
+
*/
|
|
208
|
+
function sriHash(source, hashAlgorithm) {
|
|
209
|
+
const hash = node_crypto.createHash(hashAlgorithm);
|
|
210
|
+
hash.update(typeof source === 'string' ? source : Buffer.from(source));
|
|
211
|
+
return `${hashAlgorithm}-${hash.digest('base64')}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Anything carrying a scheme (data:, blob:, invalid:) is not a path into the
|
|
215
|
+
// bundle. Protocol-relative `//host/path` is excluded here - it is a real HTTP
|
|
216
|
+
// URL and is fetched, not skipped.
|
|
217
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
218
|
+
const HTTP_RE = /^https?:/i;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The URL to fetch for an external resource, or null if it is not fetchable.
|
|
222
|
+
*/
|
|
223
|
+
function externalUrl(url) {
|
|
224
|
+
if (HTTP_RE.test(url)) return url
|
|
225
|
+
// Protocol-relative: same origin scheme as the page, https at build time
|
|
226
|
+
if (url.startsWith('//')) return `https:${url}`
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
185
230
|
/**
|
|
186
231
|
* Improved method for getting bundle keys
|
|
187
232
|
*/
|
|
188
233
|
function getBundleKey(htmlPath, url, config) {
|
|
234
|
+
// Bundle keys never carry a query string or fragment
|
|
235
|
+
const cleanUrl = url.replace(/[?#].*$/, '');
|
|
236
|
+
|
|
189
237
|
// Handle absolute path URLs
|
|
190
|
-
if (
|
|
238
|
+
if (cleanUrl.startsWith('/')) {
|
|
191
239
|
// Remove leading slash to match keys in bundle
|
|
192
|
-
return
|
|
240
|
+
return cleanUrl.substring(1)
|
|
193
241
|
}
|
|
194
242
|
|
|
195
|
-
// Handle relative paths (when config.base is relative)
|
|
243
|
+
// Handle relative paths (when config.base is relative). `join`, not
|
|
244
|
+
// `resolve` - bundle keys are relative to the output root, and `resolve`
|
|
245
|
+
// would make the key absolute against the process CWD.
|
|
196
246
|
if (config.base === './' || config.base === '') {
|
|
197
|
-
return path.posix.
|
|
247
|
+
return path.posix.join(path.posix.dirname(htmlPath), cleanUrl)
|
|
198
248
|
}
|
|
199
249
|
|
|
200
250
|
// Handle other cases, remove base prefix from URL
|
|
201
|
-
return
|
|
202
|
-
?
|
|
203
|
-
:
|
|
251
|
+
return cleanUrl.startsWith(config.base)
|
|
252
|
+
? cleanUrl.substring(config.base.length)
|
|
253
|
+
: cleanUrl
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Find a bundle key for a URL that did not match exactly.
|
|
258
|
+
*
|
|
259
|
+
* Matching is anchored on a path separator so `main.js` can never match
|
|
260
|
+
* `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
|
|
261
|
+
* but wrong hash, which the browser rejects with no build-time error.
|
|
262
|
+
*/
|
|
263
|
+
function findBundleKey(bundle, bundleKey, logger = null) {
|
|
264
|
+
const candidates = Object.keys(bundle).filter(key =>
|
|
265
|
+
key === bundleKey ||
|
|
266
|
+
key.endsWith(`/${bundleKey}`) ||
|
|
267
|
+
bundleKey.endsWith(`/${key}`)
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (candidates.length > 1 && logger) {
|
|
271
|
+
logger.warn(
|
|
272
|
+
`Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return candidates[0]
|
|
204
277
|
}
|
|
205
278
|
|
|
206
279
|
/**
|
|
207
280
|
* Calculate SRI integrity hash for a given resource
|
|
208
281
|
*/
|
|
209
282
|
async function calculateIntegrity(
|
|
210
|
-
bundle,
|
|
211
|
-
htmlPath,
|
|
212
|
-
url,
|
|
213
|
-
options,
|
|
214
|
-
config,
|
|
283
|
+
bundle,
|
|
284
|
+
htmlPath,
|
|
285
|
+
url,
|
|
286
|
+
options,
|
|
287
|
+
config,
|
|
215
288
|
cacheManager,
|
|
216
289
|
logger = null
|
|
217
290
|
) {
|
|
218
|
-
const {
|
|
219
|
-
ignoreMissingAsset,
|
|
220
|
-
bypassDomains,
|
|
221
|
-
hashAlgorithm
|
|
291
|
+
const {
|
|
292
|
+
ignoreMissingAsset,
|
|
293
|
+
bypassDomains,
|
|
294
|
+
hashAlgorithm,
|
|
295
|
+
hashedAssets
|
|
222
296
|
} = options;
|
|
223
297
|
|
|
224
298
|
// Skip specified domains
|
|
@@ -226,26 +300,43 @@ async function calculateIntegrity(
|
|
|
226
300
|
return null
|
|
227
301
|
}
|
|
228
302
|
|
|
303
|
+
// With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
|
|
304
|
+
// own build output. Those must be hashed from the bundle, not fetched - the
|
|
305
|
+
// CDN may not have been deployed yet, and this is the very case SRI exists
|
|
306
|
+
// for. Checked before the network path.
|
|
307
|
+
const base = config.base || '/';
|
|
308
|
+
const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
|
|
309
|
+
const fetchUrl = ownAsset ? null : externalUrl(url);
|
|
310
|
+
|
|
229
311
|
let source;
|
|
230
|
-
|
|
231
|
-
|
|
312
|
+
let bundleFileName = null;
|
|
313
|
+
if (fetchUrl) {
|
|
314
|
+
const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
|
|
232
315
|
if (!isSupported) return null
|
|
233
|
-
source = await fetchResource(
|
|
316
|
+
source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
|
|
234
317
|
if (!source) return null
|
|
318
|
+
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
319
|
+
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
320
|
+
if (logger) {
|
|
321
|
+
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
322
|
+
}
|
|
323
|
+
return null
|
|
235
324
|
} else {
|
|
236
325
|
const bundleKey = getBundleKey(htmlPath, url, config);
|
|
237
326
|
const bundleItem = bundle[bundleKey];
|
|
238
327
|
|
|
239
328
|
if (!bundleItem) {
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
329
|
+
// Fall back to a path-anchored suffix match to absorb hashed filenames
|
|
330
|
+
// AND base-prefix mismatches (e.g. URL "/base/main.js" with bare bundle
|
|
331
|
+
// key "main.js").
|
|
332
|
+
const possibleMatch = findBundleKey(bundle, bundleKey, logger);
|
|
244
333
|
|
|
245
334
|
if (possibleMatch) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
335
|
+
if (logger) {
|
|
336
|
+
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
337
|
+
}
|
|
338
|
+
bundleFileName = possibleMatch;
|
|
339
|
+
source = bundleSource(bundle[possibleMatch]);
|
|
249
340
|
} else if (ignoreMissingAsset) {
|
|
250
341
|
if (logger) {
|
|
251
342
|
logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
|
|
@@ -255,33 +346,65 @@ async function calculateIntegrity(
|
|
|
255
346
|
throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
|
|
256
347
|
}
|
|
257
348
|
} else {
|
|
258
|
-
|
|
349
|
+
bundleFileName = bundleKey;
|
|
350
|
+
source = bundleSource(bundleItem);
|
|
259
351
|
}
|
|
260
352
|
}
|
|
261
353
|
|
|
262
354
|
// Ensure source is a Uint8Array or string
|
|
263
355
|
if (!source) return null
|
|
264
356
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
357
|
+
const integrity = sriHash(source, hashAlgorithm);
|
|
358
|
+
// Recorded so writeBundle can catch a later plugin rewriting these bytes
|
|
359
|
+
if (bundleFileName && hashedAssets) hashedAssets.set(bundleFileName, integrity);
|
|
360
|
+
return integrity
|
|
361
|
+
}
|
|
268
362
|
|
|
269
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Match an attribute regardless of quoting style. Attribute order inside a tag
|
|
365
|
+
* is not significant in HTML, so attributes are read out of the matched tag
|
|
366
|
+
* rather than being baked into the tag regex.
|
|
367
|
+
*/
|
|
368
|
+
function attrPattern(name) {
|
|
369
|
+
// Lookbehind on whitespace, not `\b` - `\b` matches after the hyphen in
|
|
370
|
+
// `data-src`, and getAttr takes the first match, so a decoy attribute would
|
|
371
|
+
// hijack the URL.
|
|
372
|
+
return new RegExp(`(?<=\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Attributes are always preceded by whitespace inside a tag, so anchoring on
|
|
376
|
+
// it avoids matching `data-integrity`. `crossorigin` is matched with or without
|
|
377
|
+
// a value - Vite emits the valueless form, and duplicating it is invalid HTML.
|
|
378
|
+
const CROSSORIGIN_ATTR_RE = /\scrossorigin(?=[\s=>/]|$)/i;
|
|
379
|
+
const INTEGRITY_ATTR_RE = /\sintegrity\s*=/i;
|
|
380
|
+
|
|
381
|
+
const SRC_RE = attrPattern('src');
|
|
382
|
+
const HREF_RE = attrPattern('href');
|
|
383
|
+
const REL_RE = attrPattern('rel');
|
|
384
|
+
|
|
385
|
+
// rel values whose tags carry an integrity attribute
|
|
386
|
+
const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
|
|
387
|
+
|
|
388
|
+
function getAttr(tag, re) {
|
|
389
|
+
const match = tag.match(re);
|
|
390
|
+
if (!match) return null
|
|
391
|
+
return match[1] ?? match[2] ?? match[3] ?? null
|
|
270
392
|
}
|
|
271
393
|
|
|
272
|
-
// Optimized regex patterns for better readability and efficiency
|
|
273
394
|
const HTML_PATTERNS = {
|
|
274
395
|
script: {
|
|
275
|
-
regex: /<script\b[^>]
|
|
276
|
-
endOffset: 10
|
|
277
|
-
|
|
278
|
-
stylesheet: {
|
|
279
|
-
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
280
|
-
endOffset: 1
|
|
396
|
+
regex: /<script\b[^>]*><\/script>/gi,
|
|
397
|
+
endOffset: 10, // length of '></script>'
|
|
398
|
+
getUrl: tag => getAttr(tag, SRC_RE)
|
|
281
399
|
},
|
|
282
|
-
|
|
283
|
-
regex: /<link\b[^>]
|
|
284
|
-
endOffset: 1
|
|
400
|
+
link: {
|
|
401
|
+
regex: /<link\b[^>]*>/gi,
|
|
402
|
+
endOffset: 1, // length of '>'
|
|
403
|
+
getUrl: tag => {
|
|
404
|
+
const rel = getAttr(tag, REL_RE);
|
|
405
|
+
if (!rel || !SRI_LINK_RELS.has(rel.trim().toLowerCase())) return null
|
|
406
|
+
return getAttr(tag, HREF_RE)
|
|
407
|
+
}
|
|
285
408
|
}
|
|
286
409
|
};
|
|
287
410
|
|
|
@@ -296,29 +419,42 @@ function validateHtmlInput(html, htmlPath, logger) {
|
|
|
296
419
|
return true
|
|
297
420
|
}
|
|
298
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Offset inside the matched tag where new attributes should go: just before the
|
|
424
|
+
* closing `>`, skipping back over the self-closing slash and any whitespace so
|
|
425
|
+
* `<link ... />` does not become `<link ... / integrity="...">`.
|
|
426
|
+
*/
|
|
427
|
+
function insertOffset(tag, endOffset) {
|
|
428
|
+
let at = tag.length - endOffset;
|
|
429
|
+
while (at > 0 && (tag[at - 1] === '/' || /\s/.test(tag[at - 1]))) at--;
|
|
430
|
+
return at
|
|
431
|
+
}
|
|
432
|
+
|
|
299
433
|
/**
|
|
300
434
|
* Process a single match to create an integrity change object
|
|
301
435
|
*/
|
|
302
436
|
async function processMatch(
|
|
303
|
-
match,
|
|
304
|
-
|
|
305
|
-
bundle,
|
|
306
|
-
htmlPath,
|
|
307
|
-
options,
|
|
308
|
-
config,
|
|
437
|
+
match,
|
|
438
|
+
pattern,
|
|
439
|
+
bundle,
|
|
440
|
+
htmlPath,
|
|
441
|
+
options,
|
|
442
|
+
config,
|
|
309
443
|
cacheManager,
|
|
310
444
|
logger
|
|
311
445
|
) {
|
|
312
|
-
const
|
|
446
|
+
const tag = match[0];
|
|
447
|
+
if (INTEGRITY_ATTR_RE.test(tag)) return null
|
|
448
|
+
|
|
449
|
+
const url = pattern.getUrl(tag);
|
|
313
450
|
if (!url) return null
|
|
314
451
|
|
|
315
|
-
const end = match.index + match[0].length;
|
|
316
452
|
const integrity = await calculateIntegrity(
|
|
317
|
-
bundle,
|
|
318
|
-
htmlPath,
|
|
319
|
-
url,
|
|
320
|
-
options,
|
|
321
|
-
config,
|
|
453
|
+
bundle,
|
|
454
|
+
htmlPath,
|
|
455
|
+
url,
|
|
456
|
+
options,
|
|
457
|
+
config,
|
|
322
458
|
cacheManager,
|
|
323
459
|
logger
|
|
324
460
|
);
|
|
@@ -326,7 +462,8 @@ async function processMatch(
|
|
|
326
462
|
if (integrity) {
|
|
327
463
|
return {
|
|
328
464
|
integrity,
|
|
329
|
-
position:
|
|
465
|
+
position: match.index + insertOffset(tag, pattern.endOffset),
|
|
466
|
+
tag,
|
|
330
467
|
url // For logging
|
|
331
468
|
}
|
|
332
469
|
}
|
|
@@ -337,22 +474,21 @@ async function processMatch(
|
|
|
337
474
|
* Process matches for a specific HTML pattern
|
|
338
475
|
*/
|
|
339
476
|
async function processPatternMatches(
|
|
340
|
-
html,
|
|
341
|
-
pattern,
|
|
342
|
-
bundle,
|
|
343
|
-
htmlPath,
|
|
344
|
-
options,
|
|
345
|
-
config,
|
|
477
|
+
html,
|
|
478
|
+
pattern,
|
|
479
|
+
bundle,
|
|
480
|
+
htmlPath,
|
|
481
|
+
options,
|
|
482
|
+
config,
|
|
346
483
|
cacheManager,
|
|
347
484
|
logger
|
|
348
485
|
) {
|
|
349
|
-
const
|
|
350
|
-
const matches = [...html.matchAll(regex)];
|
|
486
|
+
const matches = [...html.matchAll(pattern.regex)];
|
|
351
487
|
|
|
352
488
|
// Process each match in parallel
|
|
353
489
|
const matchResults = await Promise.all(
|
|
354
|
-
matches.map(match =>
|
|
355
|
-
processMatch(match,
|
|
490
|
+
matches.map(match =>
|
|
491
|
+
processMatch(match, pattern, bundle, htmlPath, options, config, cacheManager, logger)
|
|
356
492
|
)
|
|
357
493
|
);
|
|
358
494
|
|
|
@@ -364,11 +500,11 @@ async function processPatternMatches(
|
|
|
364
500
|
* Collect all integrity changes from HTML patterns
|
|
365
501
|
*/
|
|
366
502
|
async function collectIntegrityChanges(
|
|
367
|
-
html,
|
|
368
|
-
bundle,
|
|
369
|
-
htmlPath,
|
|
370
|
-
options,
|
|
371
|
-
config,
|
|
503
|
+
html,
|
|
504
|
+
bundle,
|
|
505
|
+
htmlPath,
|
|
506
|
+
options,
|
|
507
|
+
config,
|
|
372
508
|
cacheManager,
|
|
373
509
|
logger
|
|
374
510
|
) {
|
|
@@ -378,12 +514,12 @@ async function collectIntegrityChanges(
|
|
|
378
514
|
await Promise.all(
|
|
379
515
|
Object.values(HTML_PATTERNS).map(async pattern => {
|
|
380
516
|
const patternChanges = await processPatternMatches(
|
|
381
|
-
html,
|
|
382
|
-
pattern,
|
|
383
|
-
bundle,
|
|
384
|
-
htmlPath,
|
|
385
|
-
options,
|
|
386
|
-
config,
|
|
517
|
+
html,
|
|
518
|
+
pattern,
|
|
519
|
+
bundle,
|
|
520
|
+
htmlPath,
|
|
521
|
+
options,
|
|
522
|
+
config,
|
|
387
523
|
cacheManager,
|
|
388
524
|
logger
|
|
389
525
|
);
|
|
@@ -394,13 +530,9 @@ async function collectIntegrityChanges(
|
|
|
394
530
|
return changes
|
|
395
531
|
}
|
|
396
532
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
function hasExistingIntegrity(html, position, integrity) {
|
|
401
|
-
const segment = html.slice(Math.max(0, position - 100), position + 100);
|
|
402
|
-
return segment.includes(`integrity="${integrity}"`)
|
|
403
|
-
}
|
|
533
|
+
// Attributes are always preceded by whitespace inside a tag, so anchoring on
|
|
534
|
+
// it avoids matching `data-integrity`. `crossorigin` is matched with or without
|
|
535
|
+
// a value - Vite emits the valueless form, and duplicating it is invalid HTML.
|
|
404
536
|
|
|
405
537
|
/**
|
|
406
538
|
* Apply integrity changes to HTML content
|
|
@@ -409,13 +541,11 @@ function applyIntegrityChanges(html, changes, logger) {
|
|
|
409
541
|
// Sort by position in descending order to insert from back to front
|
|
410
542
|
changes.sort((a, b) => b.position - a.position);
|
|
411
543
|
|
|
412
|
-
for (const { integrity, position, url } of changes) {
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
544
|
+
for (const { integrity, position, tag, url } of changes) {
|
|
545
|
+
let insertText = ` integrity="${integrity}"`;
|
|
546
|
+
if (!CROSSORIGIN_ATTR_RE.test(tag)) {
|
|
547
|
+
insertText += ' crossorigin="anonymous"';
|
|
416
548
|
}
|
|
417
|
-
|
|
418
|
-
const insertText = ` integrity="${integrity}"`;
|
|
419
549
|
html = html.slice(0, position) + insertText + html.slice(position);
|
|
420
550
|
logger.debug(`Added integrity for: ${url}`);
|
|
421
551
|
}
|
|
@@ -423,15 +553,49 @@ function applyIntegrityChanges(html, changes, logger) {
|
|
|
423
553
|
return html
|
|
424
554
|
}
|
|
425
555
|
|
|
556
|
+
const EXISTING_IMPORTMAP_RE = /<script\b[^>]*\btype\s*=\s*["']importmap["']/i;
|
|
557
|
+
const FIRST_SCRIPT_RE = /<script\b/i;
|
|
558
|
+
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Inject an import map carrying an `integrity` map.
|
|
562
|
+
*
|
|
563
|
+
* This is the only mechanism that covers modules pulled in at runtime by
|
|
564
|
+
* `import()` / Vite's preload helper, which have no build-time HTML tag to
|
|
565
|
+
* rewrite. Engines without support ignore the key rather than failing.
|
|
566
|
+
*/
|
|
567
|
+
function injectImportmapIntegrity(html, integrity, logger) {
|
|
568
|
+
if (!html || typeof html !== 'string' || Object.keys(integrity).length === 0) {
|
|
569
|
+
return html
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (EXISTING_IMPORTMAP_RE.test(html)) {
|
|
573
|
+
logger.warn('HTML already contains an import map; skipping SRI import map injection');
|
|
574
|
+
return html
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// `<` is escaped so a filename can never close the script element early
|
|
578
|
+
const json = JSON.stringify({ integrity }).replace(/</g, '\\u003c');
|
|
579
|
+
const tag = `<script type="importmap">${json}</script>`;
|
|
580
|
+
|
|
581
|
+
// Must precede every module script, otherwise the map does not apply to them
|
|
582
|
+
for (const re of [FIRST_SCRIPT_RE, HEAD_CLOSE_RE]) {
|
|
583
|
+
const at = html.search(re);
|
|
584
|
+
if (at !== -1) return html.slice(0, at) + tag + html.slice(at)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return html + tag
|
|
588
|
+
}
|
|
589
|
+
|
|
426
590
|
/**
|
|
427
591
|
* Transform HTML by adding SRI integrity attributes
|
|
428
592
|
*/
|
|
429
593
|
async function transformHTML(
|
|
430
|
-
bundle,
|
|
431
|
-
htmlPath,
|
|
432
|
-
html,
|
|
433
|
-
options,
|
|
434
|
-
config,
|
|
594
|
+
bundle,
|
|
595
|
+
htmlPath,
|
|
596
|
+
html,
|
|
597
|
+
options,
|
|
598
|
+
config,
|
|
435
599
|
cacheManager,
|
|
436
600
|
logger
|
|
437
601
|
) {
|
|
@@ -440,11 +604,11 @@ async function transformHTML(
|
|
|
440
604
|
}
|
|
441
605
|
|
|
442
606
|
const changes = await collectIntegrityChanges(
|
|
443
|
-
html,
|
|
444
|
-
bundle,
|
|
445
|
-
htmlPath,
|
|
446
|
-
options,
|
|
447
|
-
config,
|
|
607
|
+
html,
|
|
608
|
+
bundle,
|
|
609
|
+
htmlPath,
|
|
610
|
+
options,
|
|
611
|
+
config,
|
|
448
612
|
cacheManager,
|
|
449
613
|
logger
|
|
450
614
|
);
|
|
@@ -457,9 +621,9 @@ async function transformHTML(
|
|
|
457
621
|
*/
|
|
458
622
|
function createTransformer(options, config, cacheManager, logger) {
|
|
459
623
|
return {
|
|
460
|
-
transformHTML: (bundle, htmlPath, html) =>
|
|
624
|
+
transformHTML: (bundle, htmlPath, html) =>
|
|
461
625
|
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
|
|
462
|
-
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
626
|
+
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
463
627
|
calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
|
|
464
628
|
}
|
|
465
629
|
}
|
|
@@ -480,7 +644,9 @@ class Logger {
|
|
|
480
644
|
info: 3,
|
|
481
645
|
debug: 4
|
|
482
646
|
};
|
|
483
|
-
|
|
647
|
+
// `??`, not `||` - levels.silent is 0, which `||` treats as absent and
|
|
648
|
+
// silently downgrades to 'warn', the one level that must suppress output.
|
|
649
|
+
this.currentLevel = this.levels[logLevel] ?? this.levels.warn;
|
|
484
650
|
}
|
|
485
651
|
|
|
486
652
|
/**
|
|
@@ -538,88 +704,198 @@ class Logger {
|
|
|
538
704
|
}
|
|
539
705
|
}
|
|
540
706
|
|
|
541
|
-
//
|
|
542
|
-
|
|
707
|
+
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
|
|
708
|
+
// adds `native:import-analysis-build`. We patch whichever (or both) is present.
|
|
709
|
+
//
|
|
710
|
+
// Why patch at all, rather than use `transformIndexHtml` (order: 'post') or our
|
|
711
|
+
// own `enforce: 'post'` generateBundle? Both of those run BEFORE this plugin
|
|
712
|
+
// substitutes `__VITE_PRELOAD__` in entry chunks, so any hash taken there
|
|
713
|
+
// describes bytes that never ship. Measured on Vite 8.2.2: at both of those
|
|
714
|
+
// points the entry chunk still ends `import("./about-*.js"),__VITE_PRELOAD__)`,
|
|
715
|
+
// while the written file ends `import("./about-*.js"),[])`. Wrapping this
|
|
716
|
+
// plugin's own handler is the only hook position after that substitution.
|
|
717
|
+
// `test/sri.test.js > injects integrity matching the actual emitted bytes`
|
|
718
|
+
// fails if this is ever "simplified" away.
|
|
719
|
+
const VITE_INTERNAL_ANALYSIS_PLUGINS = [
|
|
720
|
+
'vite:build-import-analysis',
|
|
721
|
+
'native:import-analysis-build'
|
|
722
|
+
];
|
|
543
723
|
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
544
724
|
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
725
|
+
const MANIFEST_FILE_NAME = 'sri-manifest.json';
|
|
726
|
+
const HTML_RE = /\.html?$/;
|
|
727
|
+
const JS_MODULE_RE = /\.m?js$/;
|
|
728
|
+
|
|
729
|
+
function toText(source) {
|
|
730
|
+
return typeof source === 'string' ? source : Buffer.from(source).toString('utf8')
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function withTrailingSlash(base) {
|
|
734
|
+
if (!base) return '/'
|
|
735
|
+
return base.endsWith('/') ? base : `${base}/`
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Hash every non-HTML output, keyed by bundle file name.
|
|
740
|
+
*/
|
|
741
|
+
function hashBundle(bundle, hashAlgorithm) {
|
|
742
|
+
const hashes = {};
|
|
743
|
+
for (const [fileName, item] of Object.entries(bundle)) {
|
|
744
|
+
if (HTML_RE.test(fileName)) continue
|
|
745
|
+
const source = bundleSource(item);
|
|
746
|
+
if (source) hashes[fileName] = sriHash(source, hashAlgorithm);
|
|
747
|
+
}
|
|
748
|
+
return hashes
|
|
749
|
+
}
|
|
545
750
|
|
|
546
751
|
function sri(options = {}) {
|
|
547
752
|
const {
|
|
548
753
|
ignoreMissingAsset = false,
|
|
549
754
|
bypassDomains = [],
|
|
550
755
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
551
|
-
logLevel = 'warn'
|
|
756
|
+
logLevel = 'warn',
|
|
757
|
+
manifest = false,
|
|
758
|
+
importmap = false
|
|
552
759
|
} = options;
|
|
553
760
|
|
|
554
761
|
// Create cache manager and logger instances for this plugin instance
|
|
555
762
|
const cacheManager = new CacheManager();
|
|
556
763
|
const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
|
|
557
764
|
|
|
765
|
+
// bundle fileName -> the integrity we injected, re-checked in writeBundle
|
|
766
|
+
const hashedAssets = new Map();
|
|
767
|
+
|
|
558
768
|
return {
|
|
559
769
|
name: DEFAULT_PLUGIN_NAME,
|
|
560
770
|
enforce: 'post',
|
|
561
771
|
apply: 'build',
|
|
562
772
|
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
773
|
+
// Every generateBundle hook has run by now. A plugin that rewrites chunk
|
|
774
|
+
// contents after ours (plugin-legacy, in-place compression) would leave the
|
|
775
|
+
// injected hashes describing bytes that no longer ship - a green build that
|
|
776
|
+
// only fails in the browser. Fail here instead.
|
|
777
|
+
writeBundle(_, bundle) {
|
|
778
|
+
const drifted = [];
|
|
779
|
+
for (const [fileName, integrity] of hashedAssets) {
|
|
780
|
+
const item = bundle[fileName];
|
|
781
|
+
if (!item) continue
|
|
782
|
+
const source = bundleSource(item);
|
|
783
|
+
if (source && sriHash(source, hashAlgorithm) !== integrity) {
|
|
784
|
+
drifted.push(fileName);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
hashedAssets.clear();
|
|
788
|
+
|
|
789
|
+
if (drifted.length > 0) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`[${DEFAULT_PLUGIN_NAME}] content changed after integrity was computed: ` +
|
|
792
|
+
`${drifted.join(', ')}. A plugin running after this one rewrote these ` +
|
|
793
|
+
`files, so the injected hashes no longer match what ships. Move that ` +
|
|
794
|
+
`plugin before ${DEFAULT_PLUGIN_NAME}, or drop it.`
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
|
|
799
|
+
// Cleanup. Note this must not be `buildEnd`, which Rollup runs before the
|
|
800
|
+
// output phase - clearing there would empty the caches before use.
|
|
801
|
+
closeBundle() {
|
|
566
802
|
cacheManager.clearAll();
|
|
803
|
+
hashedAssets.clear();
|
|
567
804
|
},
|
|
568
805
|
|
|
569
806
|
configResolved(config) {
|
|
570
807
|
const transformer = createTransformer({
|
|
571
808
|
ignoreMissingAsset,
|
|
572
809
|
bypassDomains,
|
|
573
|
-
hashAlgorithm
|
|
810
|
+
hashAlgorithm,
|
|
811
|
+
hashedAssets
|
|
574
812
|
}, config, cacheManager, logger);
|
|
575
813
|
|
|
814
|
+
// Both analysis plugin names can be present at once, and each is wrapped.
|
|
815
|
+
// Without this guard the body runs twice per bundle: `emitFile` throws on
|
|
816
|
+
// the duplicate manifest fileName, and the import map warns about the one
|
|
817
|
+
// it just injected. If the skipped pass was the one that finalised chunk
|
|
818
|
+
// contents, the writeBundle drift check fails the build loudly.
|
|
819
|
+
const handled = new WeakSet();
|
|
820
|
+
|
|
576
821
|
const generateBundle = async function(_, bundle) {
|
|
822
|
+
if (handled.has(bundle)) return
|
|
823
|
+
handled.add(bundle);
|
|
824
|
+
|
|
825
|
+
// Computed before emitting anything so the manifest never hashes itself
|
|
826
|
+
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
827
|
+
|
|
577
828
|
const htmlFiles = Object.entries(bundle).filter(
|
|
578
829
|
([, chunk]) =>
|
|
579
830
|
chunk.type === 'asset' &&
|
|
580
|
-
|
|
831
|
+
HTML_RE.test(chunk.fileName)
|
|
581
832
|
);
|
|
582
833
|
|
|
583
834
|
if (htmlFiles.length === 0) {
|
|
835
|
+
// Normal for SSR / library builds, which render HTML at request time
|
|
836
|
+
// - the manifest below is how those builds get their hashes.
|
|
584
837
|
logger.debug('No HTML files found in bundle');
|
|
585
|
-
return
|
|
586
838
|
}
|
|
587
839
|
|
|
588
|
-
//
|
|
840
|
+
// Errors are intentionally not caught: a resource that cannot be
|
|
841
|
+
// hashed must fail the build rather than ship without integrity.
|
|
589
842
|
await Promise.all(
|
|
590
843
|
htmlFiles.map(async ([name, chunk]) => {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
844
|
+
const originalContent = toText(chunk.source);
|
|
845
|
+
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
846
|
+
|
|
847
|
+
if (importmap) {
|
|
848
|
+
const moduleIntegrity = {};
|
|
849
|
+
const base = withTrailingSlash(config.base);
|
|
850
|
+
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
851
|
+
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
597
852
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
853
|
+
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
chunk.source = html;
|
|
857
|
+
|
|
858
|
+
if (originalContent !== chunk.source) {
|
|
859
|
+
logger.debug(`SRI attributes added to ${name}`);
|
|
601
860
|
}
|
|
602
861
|
})
|
|
603
862
|
);
|
|
863
|
+
|
|
864
|
+
if (manifest) {
|
|
865
|
+
this.emitFile({
|
|
866
|
+
type: 'asset',
|
|
867
|
+
fileName: MANIFEST_FILE_NAME,
|
|
868
|
+
source: JSON.stringify(hashes, null, 2)
|
|
869
|
+
});
|
|
870
|
+
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
871
|
+
}
|
|
604
872
|
};
|
|
605
873
|
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
874
|
+
const targets = config.plugins.filter(
|
|
875
|
+
p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
|
|
876
|
+
);
|
|
877
|
+
if (targets.length === 0) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
|
|
880
|
+
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
881
|
+
`Requires Vite 6.0.0 or higher.`
|
|
882
|
+
)
|
|
609
883
|
}
|
|
610
884
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
885
|
+
for (const plugin of targets) {
|
|
886
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
887
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
888
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
889
|
+
await originalHandler.apply(this, args);
|
|
890
|
+
await generateBundle.apply(this, args);
|
|
891
|
+
};
|
|
892
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
893
|
+
const originalHandler = plugin.generateBundle;
|
|
894
|
+
plugin.generateBundle = async function(...args) {
|
|
895
|
+
await originalHandler.apply(this, args);
|
|
896
|
+
await generateBundle.apply(this, args);
|
|
897
|
+
};
|
|
898
|
+
}
|
|
623
899
|
}
|
|
624
900
|
}
|
|
625
901
|
}
|