vite-plugin-sri4 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +124 -19
- package/dist/index.cjs +367 -137
- package/dist/index.js +367 -137
- package/package.json +12 -8
- package/types/index.d.ts +72 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import fetch from 'cross-fetch';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Extended caching mechanism with expiration time
|
|
@@ -63,17 +63,21 @@ class CacheManager {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Global fetch, stable since Node 18 - the floor the Vite 6.4 peer range
|
|
67
|
+
// already implies. No dependency needed.
|
|
66
68
|
const DEFAULT_TIMEOUT = 5000;
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
|
-
*
|
|
71
|
+
* Does an external URL's host match one of `domains`, or a subdomain of one?
|
|
72
|
+
* Used by both `bypassDomains` and `trustDomains`.
|
|
70
73
|
*/
|
|
71
|
-
function
|
|
74
|
+
function matchesDomain(url, domains = [], logger = null) {
|
|
72
75
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
76
|
+
if (domains.length === 0) return false
|
|
73
77
|
|
|
74
78
|
try {
|
|
75
79
|
const urlObj = new URL(url);
|
|
76
|
-
return
|
|
80
|
+
return domains.some(domain =>
|
|
77
81
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
78
82
|
)
|
|
79
83
|
} catch (error) {
|
|
@@ -84,10 +88,80 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
|
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
// A year. The conventional encoding of "this URL's bytes will never change",
|
|
92
|
+
// and what every CDN puts on a version-pinned path.
|
|
93
|
+
const IMMUTABLE_MAX_AGE = 31536000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Does the origin declare this URL's bytes immutable - `Cache-Control:
|
|
97
|
+
* immutable`, or a max-age of a year or more - and nothing in the same header
|
|
98
|
+
* contradicting it?
|
|
99
|
+
*
|
|
100
|
+
* Measured, because the split is what makes this usable as a gate. Pinned
|
|
101
|
+
* third-party libraries, the case SRI actually exists for:
|
|
102
|
+
*
|
|
103
|
+
* cdnjs jquery/3.7.1 max-age=30672000, immutable
|
|
104
|
+
* jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
|
|
105
|
+
* unpkg htmx.org@1.9.12 max-age=31536000
|
|
106
|
+
* code.jquery.com 3.7.1 max-age=31536000
|
|
107
|
+
*
|
|
108
|
+
* Everything that rolls under a stable URL:
|
|
109
|
+
*
|
|
110
|
+
* jsdelivr vue@3 max-age=604800
|
|
111
|
+
* fonts.googleapis.com max-age=86400 (also `private`)
|
|
112
|
+
* plausible.io/js/script.js max-age=86400
|
|
113
|
+
* cdn.tailwindcss.com max-age=14400
|
|
114
|
+
* connect.facebook.net max-age=1200
|
|
115
|
+
* js.stripe.com/v3/ max-age=120
|
|
116
|
+
* unpkg react@18 max-age=60
|
|
117
|
+
*
|
|
118
|
+
* Nothing lands between 604800 and 30672000, so the threshold is not a
|
|
119
|
+
* balancing act - it separates two clusters the CDNs themselves created.
|
|
120
|
+
*/
|
|
121
|
+
function isImmutableResponse(cacheControl) {
|
|
122
|
+
if (!cacheControl) return false
|
|
123
|
+
|
|
124
|
+
let immutable = false;
|
|
125
|
+
|
|
126
|
+
for (const directive of cacheControl.split(',')) {
|
|
127
|
+
// Token equality, not substring: `x-immutable` is not this directive
|
|
128
|
+
const token = directive.trim().toLowerCase();
|
|
129
|
+
|
|
130
|
+
// These veto whatever else the header claims, and are checked against the
|
|
131
|
+
// whole header rather than returning early, because freshness and
|
|
132
|
+
// shareability are orthogonal - a per-client response can carry a long
|
|
133
|
+
// max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
|
|
134
|
+
// it, but revalidate every time", i.e. the bytes may have changed.
|
|
135
|
+
//
|
|
136
|
+
// The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
|
|
137
|
+
// only scope the directive to those headers, so vetoing on them is
|
|
138
|
+
// stricter than the spec requires. That is the right way to be wrong here:
|
|
139
|
+
// the cost is losing SRI on a resource that would have been fine, and it
|
|
140
|
+
// is logged. Not vetoing costs a page that only breaks in the browser.
|
|
141
|
+
if (
|
|
142
|
+
token === 'no-store' ||
|
|
143
|
+
token === 'private' || token.startsWith('private=') ||
|
|
144
|
+
token === 'no-cache' || token.startsWith('no-cache=')
|
|
145
|
+
) {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (token === 'immutable') immutable = true;
|
|
150
|
+
|
|
151
|
+
if (token.startsWith('max-age=')) {
|
|
152
|
+
// RFC 9111 permits a quoted-string value: `max-age="31536000"`
|
|
153
|
+
const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
|
|
154
|
+
if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return immutable
|
|
159
|
+
}
|
|
160
|
+
|
|
87
161
|
/**
|
|
88
162
|
* Resource check with retry mechanism
|
|
89
163
|
*/
|
|
90
|
-
async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
|
|
164
|
+
async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
|
|
91
165
|
if (urlSupportCache.has(url)) {
|
|
92
166
|
return urlSupportCache.get(url)
|
|
93
167
|
}
|
|
@@ -105,23 +179,67 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
|
|
|
105
179
|
|
|
106
180
|
clearTimeout(timeoutId);
|
|
107
181
|
|
|
182
|
+
// Every path out of here that skips a resource says why. A tag that
|
|
183
|
+
// silently ships without integrity is the thing that is easy to miss.
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
if (logger) {
|
|
186
|
+
logger.warn(
|
|
187
|
+
`Skipping SRI for ${url}: HEAD returned ${response.status}, so the resource ` +
|
|
188
|
+
'could not be checked. Add the domain to bypassDomains to silence this.'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
urlSupportCache.set(url, false);
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
108
195
|
// Only `*` can be verified at build time. Injecting integrity also means
|
|
109
196
|
// injecting crossorigin="anonymous"; if the server answers with a
|
|
110
197
|
// concrete origin that does not match wherever the HTML ends up being
|
|
111
|
-
// served from, that turns a working script into a blocked one.
|
|
112
|
-
// is the safe outcome, but say so at warn level - silence here is what
|
|
113
|
-
// makes an unprotected resource easy to miss.
|
|
198
|
+
// served from, that turns a working script into a blocked one.
|
|
114
199
|
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
200
|
+
if (corsHeader !== '*') {
|
|
201
|
+
if (logger) {
|
|
202
|
+
logger.warn(
|
|
203
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
|
|
204
|
+
`${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
|
|
205
|
+
'cannot be verified at build time. ' +
|
|
206
|
+
'Add the domain to bypassDomains to silence this.'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
urlSupportCache.set(url, false);
|
|
210
|
+
return false
|
|
122
211
|
}
|
|
123
|
-
|
|
124
|
-
|
|
212
|
+
|
|
213
|
+
// Reachable and CORS-eligible is not the same property as byte-stable.
|
|
214
|
+
// A hash pins one snapshot of bytes forever, so it is only safe on a URL
|
|
215
|
+
// whose bytes never change - and the origin is the only party that knows.
|
|
216
|
+
// Require it to say so rather than hunting for reasons to skip: a
|
|
217
|
+
// blacklist of known-bad origins is never finished (Google Fonts is
|
|
218
|
+
// `private`, but cdn.tailwindcss.com and plausible.io are ordinary
|
|
219
|
+
// `public` responses that roll just the same), and every gap in it ships
|
|
220
|
+
// a build that works today and breaks whenever the vendor deploys.
|
|
221
|
+
//
|
|
222
|
+
// Deliberately not `vary`: Google Fonts varies on User-Agent without
|
|
223
|
+
// declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
|
|
224
|
+
// so a vary-based gate would let that resource straight through.
|
|
225
|
+
const cacheControl = response.headers.get('cache-control');
|
|
226
|
+
if (!trusted && !isImmutableResponse(cacheControl)) {
|
|
227
|
+
if (logger) {
|
|
228
|
+
logger.warn(
|
|
229
|
+
`Skipping SRI for ${url}: Cache-Control is ` +
|
|
230
|
+
`${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
|
|
231
|
+
'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
|
|
232
|
+
'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
|
|
233
|
+
'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
|
|
234
|
+
"vendor's rolling URL ships a page that breaks on their next deploy."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
urlSupportCache.set(url, false);
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
urlSupportCache.set(url, true);
|
|
242
|
+
return true
|
|
125
243
|
} catch (error) {
|
|
126
244
|
lastError = error;
|
|
127
245
|
if (error.name === 'AbortError') {
|
|
@@ -198,6 +316,10 @@ function bundleSource(item) {
|
|
|
198
316
|
return item.type === 'chunk' ? item.code : item.source
|
|
199
317
|
}
|
|
200
318
|
|
|
319
|
+
// The only algorithms the SRI spec defines. Browsers reject anything else,
|
|
320
|
+
// which blocks the resource with no build-time error at all.
|
|
321
|
+
const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
|
322
|
+
|
|
201
323
|
/**
|
|
202
324
|
* Compute an SRI string for a source that may be a string, Buffer or Uint8Array
|
|
203
325
|
*/
|
|
@@ -272,6 +394,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
|
|
|
272
394
|
return candidates[0]
|
|
273
395
|
}
|
|
274
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Read an asset that lives in `publicDir` rather than the bundle.
|
|
399
|
+
*
|
|
400
|
+
* Files copied verbatim from `public/` never appear as bundle entries, so
|
|
401
|
+
* without this a perfectly normal `<script src="/sw.js">` fails the build.
|
|
402
|
+
* Returns null rather than throwing so the caller keeps its own missing-asset
|
|
403
|
+
* policy.
|
|
404
|
+
*/
|
|
405
|
+
async function readPublicAsset(config, bundleKey, logger) {
|
|
406
|
+
const publicDir = config.publicDir;
|
|
407
|
+
if (!publicDir) return null
|
|
408
|
+
|
|
409
|
+
// Bundle keys come from URLs, which may be percent-encoded
|
|
410
|
+
let decoded;
|
|
411
|
+
try {
|
|
412
|
+
decoded = decodeURIComponent(bundleKey);
|
|
413
|
+
} catch {
|
|
414
|
+
decoded = bundleKey;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const filePath = path.resolve(publicDir, decoded);
|
|
418
|
+
|
|
419
|
+
// A URL must never reach outside publicDir, however it is spelled
|
|
420
|
+
const relative = path.relative(publicDir, filePath);
|
|
421
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
422
|
+
if (logger) {
|
|
423
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
424
|
+
}
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const source = await readFile(filePath);
|
|
430
|
+
if (logger) {
|
|
431
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
432
|
+
}
|
|
433
|
+
return source
|
|
434
|
+
} catch {
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
275
439
|
/**
|
|
276
440
|
* Calculate SRI integrity hash for a given resource
|
|
277
441
|
*/
|
|
@@ -287,15 +451,11 @@ async function calculateIntegrity(
|
|
|
287
451
|
const {
|
|
288
452
|
ignoreMissingAsset,
|
|
289
453
|
bypassDomains,
|
|
454
|
+
trustDomains,
|
|
290
455
|
hashAlgorithm,
|
|
291
456
|
hashedAssets
|
|
292
457
|
} = options;
|
|
293
458
|
|
|
294
|
-
// Skip specified domains
|
|
295
|
-
if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
|
|
296
|
-
return null
|
|
297
|
-
}
|
|
298
|
-
|
|
299
459
|
// With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
|
|
300
460
|
// own build output. Those must be hashed from the bundle, not fetched - the
|
|
301
461
|
// CDN may not have been deployed yet, and this is the very case SRI exists
|
|
@@ -304,10 +464,22 @@ async function calculateIntegrity(
|
|
|
304
464
|
const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
|
|
305
465
|
const fetchUrl = ownAsset ? null : externalUrl(url);
|
|
306
466
|
|
|
467
|
+
// Both domain options match the URL that would actually be fetched.
|
|
468
|
+
// `matchesDomain` needs a scheme, so matching the raw `url` silently missed
|
|
469
|
+
// every protocol-relative `//host/path` - and `trustDomains` below, which
|
|
470
|
+
// already saw the normalized form, would then disagree with `bypassDomains`
|
|
471
|
+
// about the same tag.
|
|
472
|
+
if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
|
|
307
476
|
let source;
|
|
308
477
|
let bundleFileName = null;
|
|
309
478
|
if (fetchUrl) {
|
|
310
|
-
const
|
|
479
|
+
const trusted = matchesDomain(fetchUrl, trustDomains, logger);
|
|
480
|
+
const isSupported = await checkResourceSupport(
|
|
481
|
+
fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
|
|
482
|
+
);
|
|
311
483
|
if (!isSupported) return null
|
|
312
484
|
source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
|
|
313
485
|
if (!source) return null
|
|
@@ -333,13 +505,23 @@ async function calculateIntegrity(
|
|
|
333
505
|
}
|
|
334
506
|
bundleFileName = possibleMatch;
|
|
335
507
|
source = bundleSource(bundle[possibleMatch]);
|
|
336
|
-
} else if (ignoreMissingAsset) {
|
|
337
|
-
if (logger) {
|
|
338
|
-
logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
|
|
339
|
-
}
|
|
340
|
-
return null
|
|
341
508
|
} else {
|
|
342
|
-
|
|
509
|
+
// Not a build output - it may still be a file copied from publicDir
|
|
510
|
+
source = await readPublicAsset(config, bundleKey, logger);
|
|
511
|
+
|
|
512
|
+
if (!source) {
|
|
513
|
+
if (ignoreMissingAsset) {
|
|
514
|
+
if (logger) {
|
|
515
|
+
logger.warn(
|
|
516
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
throw new Error(
|
|
522
|
+
`Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
|
|
523
|
+
)
|
|
524
|
+
}
|
|
343
525
|
}
|
|
344
526
|
} else {
|
|
345
527
|
bundleFileName = bundleKey;
|
|
@@ -427,7 +609,15 @@ function insertOffset(tag, endOffset) {
|
|
|
427
609
|
}
|
|
428
610
|
|
|
429
611
|
/**
|
|
430
|
-
*
|
|
612
|
+
* Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
|
|
613
|
+
* attribute is stripped so it does not ship to the browser. More granular than
|
|
614
|
+
* bypassDomains, which only reaches external hosts.
|
|
615
|
+
*/
|
|
616
|
+
const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Process a single match into a text edit: { start, end, content }. An
|
|
620
|
+
* insertion has start === end; a removal has empty content.
|
|
431
621
|
*/
|
|
432
622
|
async function processMatch(
|
|
433
623
|
match,
|
|
@@ -440,6 +630,20 @@ async function processMatch(
|
|
|
440
630
|
logger
|
|
441
631
|
) {
|
|
442
632
|
const tag = match[0];
|
|
633
|
+
|
|
634
|
+
const skip = SKIP_SRI_ATTR_RE.exec(tag);
|
|
635
|
+
if (skip) {
|
|
636
|
+
return {
|
|
637
|
+
start: match.index + skip.index,
|
|
638
|
+
end: match.index + skip.index + skip[0].length,
|
|
639
|
+
content: '',
|
|
640
|
+
url: pattern.getUrl(tag),
|
|
641
|
+
skipped: true
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Nothing to do for a tag that already carries its own hash, and computing
|
|
646
|
+
// one anyway would register it for the writeBundle drift check
|
|
443
647
|
if (INTEGRITY_ATTR_RE.test(tag)) return null
|
|
444
648
|
|
|
445
649
|
const url = pattern.getUrl(tag);
|
|
@@ -455,15 +659,15 @@ async function processMatch(
|
|
|
455
659
|
logger
|
|
456
660
|
);
|
|
457
661
|
|
|
458
|
-
if (integrity)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
url // For logging
|
|
464
|
-
}
|
|
662
|
+
if (!integrity) return null
|
|
663
|
+
|
|
664
|
+
let content = ` integrity="${integrity}"`;
|
|
665
|
+
if (!CROSSORIGIN_ATTR_RE.test(tag)) {
|
|
666
|
+
content += ` crossorigin="${options.crossorigin}"`;
|
|
465
667
|
}
|
|
466
|
-
|
|
668
|
+
|
|
669
|
+
const at = match.index + insertOffset(tag, pattern.endOffset);
|
|
670
|
+
return { start: at, end: at, content, url }
|
|
467
671
|
}
|
|
468
672
|
|
|
469
673
|
/**
|
|
@@ -534,16 +738,12 @@ async function collectIntegrityChanges(
|
|
|
534
738
|
* Apply integrity changes to HTML content
|
|
535
739
|
*/
|
|
536
740
|
function applyIntegrityChanges(html, changes, logger) {
|
|
537
|
-
//
|
|
538
|
-
changes.sort((a, b) => b.
|
|
741
|
+
// Back to front, so earlier offsets stay valid as the string is edited
|
|
742
|
+
changes.sort((a, b) => b.start - a.start);
|
|
539
743
|
|
|
540
|
-
for (const {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
insertText += ' crossorigin="anonymous"';
|
|
544
|
-
}
|
|
545
|
-
html = html.slice(0, position) + insertText + html.slice(position);
|
|
546
|
-
logger.debug(`Added integrity for: ${url}`);
|
|
744
|
+
for (const { start, end, content, url, skipped } of changes) {
|
|
745
|
+
html = html.slice(0, start) + content + html.slice(end);
|
|
746
|
+
logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
|
|
547
747
|
}
|
|
548
748
|
|
|
549
749
|
return html
|
|
@@ -700,23 +900,27 @@ class Logger {
|
|
|
700
900
|
}
|
|
701
901
|
}
|
|
702
902
|
|
|
703
|
-
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
|
|
704
|
-
//
|
|
903
|
+
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
|
904
|
+
// `native:import-analysis-build`.
|
|
905
|
+
//
|
|
906
|
+
// Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
|
|
907
|
+
// chunks inside their own generateBundle, and Vite places them immediately
|
|
908
|
+
// AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
|
|
909
|
+
// is at index 25 and the analysis plugin at 26 - so by default we would hash
|
|
910
|
+
// an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
|
|
911
|
+
// the written file contains `import("./x.js"),[])`.
|
|
705
912
|
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
// while the written file ends `import("./about-*.js"),[])`. Wrapping this
|
|
712
|
-
// plugin's own handler is the only hook position after that substitution.
|
|
713
|
-
// `test/sri.test.js > injects integrity matching the actual emitted bytes`
|
|
714
|
-
// fails if this is ever "simplified" away.
|
|
913
|
+
// The fix is to move THIS plugin one place later, not to rewrite someone
|
|
914
|
+
// else's hook. `config.plugins` is a plain, unfrozen array at configResolved
|
|
915
|
+
// time and Rollup reads it afterwards, so repositioning takes effect.
|
|
916
|
+
// `test/sri.test.js > injects integrity matching the actual emitted bytes` is
|
|
917
|
+
// the pin, and the writeBundle drift check is the second net.
|
|
715
918
|
const VITE_INTERNAL_ANALYSIS_PLUGINS = [
|
|
716
919
|
'vite:build-import-analysis',
|
|
717
920
|
'native:import-analysis-build'
|
|
718
921
|
];
|
|
719
922
|
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
923
|
+
const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
|
|
720
924
|
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
721
925
|
const MANIFEST_FILE_NAME = 'sri-manifest.json';
|
|
722
926
|
const HTML_RE = /\.html?$/;
|
|
@@ -744,16 +948,40 @@ function hashBundle(bundle, hashAlgorithm) {
|
|
|
744
948
|
return hashes
|
|
745
949
|
}
|
|
746
950
|
|
|
951
|
+
/**
|
|
952
|
+
* Reject configuration that would build cleanly and then fail in the browser.
|
|
953
|
+
*/
|
|
954
|
+
function validateOptions(hashAlgorithm, crossorigin) {
|
|
955
|
+
if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
|
|
956
|
+
throw new Error(
|
|
957
|
+
`[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
|
|
958
|
+
`The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
|
|
959
|
+
`anything else, so the build would succeed and the resource would be blocked.`
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
`[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
|
|
966
|
+
`got "${crossorigin}"`
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
747
971
|
function sri(options = {}) {
|
|
748
972
|
const {
|
|
749
973
|
ignoreMissingAsset = false,
|
|
750
974
|
bypassDomains = [],
|
|
975
|
+
trustDomains = [],
|
|
751
976
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
977
|
+
crossorigin = 'anonymous',
|
|
752
978
|
logLevel = 'warn',
|
|
753
979
|
manifest = false,
|
|
754
980
|
importmap = false
|
|
755
981
|
} = options;
|
|
756
982
|
|
|
983
|
+
validateOptions(hashAlgorithm, crossorigin);
|
|
984
|
+
|
|
757
985
|
// Create cache manager and logger instances for this plugin instance
|
|
758
986
|
const cacheManager = new CacheManager();
|
|
759
987
|
const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
|
|
@@ -761,6 +989,9 @@ function sri(options = {}) {
|
|
|
761
989
|
// bundle fileName -> the integrity we injected, re-checked in writeBundle
|
|
762
990
|
const hashedAssets = new Map();
|
|
763
991
|
|
|
992
|
+
let config;
|
|
993
|
+
let transformer;
|
|
994
|
+
|
|
764
995
|
return {
|
|
765
996
|
name: DEFAULT_PLUGIN_NAME,
|
|
766
997
|
enforce: 'post',
|
|
@@ -799,99 +1030,98 @@ function sri(options = {}) {
|
|
|
799
1030
|
hashedAssets.clear();
|
|
800
1031
|
},
|
|
801
1032
|
|
|
802
|
-
configResolved(
|
|
803
|
-
|
|
1033
|
+
configResolved(resolvedConfig) {
|
|
1034
|
+
config = resolvedConfig;
|
|
1035
|
+
transformer = createTransformer({
|
|
804
1036
|
ignoreMissingAsset,
|
|
805
1037
|
bypassDomains,
|
|
1038
|
+
trustDomains,
|
|
806
1039
|
hashAlgorithm,
|
|
1040
|
+
crossorigin,
|
|
807
1041
|
hashedAssets
|
|
808
1042
|
}, config, cacheManager, logger);
|
|
809
1043
|
|
|
810
|
-
|
|
811
|
-
// Without this guard the body runs twice per bundle: `emitFile` throws on
|
|
812
|
-
// the duplicate manifest fileName, and the import map warns about the one
|
|
813
|
-
// it just injected. If the skipped pass was the one that finalised chunk
|
|
814
|
-
// contents, the writeBundle drift check fails the build loudly.
|
|
815
|
-
const handled = new WeakSet();
|
|
816
|
-
|
|
817
|
-
const generateBundle = async function(_, bundle) {
|
|
818
|
-
if (handled.has(bundle)) return
|
|
819
|
-
handled.add(bundle);
|
|
820
|
-
|
|
821
|
-
// Computed before emitting anything so the manifest never hashes itself
|
|
822
|
-
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
823
|
-
|
|
824
|
-
const htmlFiles = Object.entries(bundle).filter(
|
|
825
|
-
([, chunk]) =>
|
|
826
|
-
chunk.type === 'asset' &&
|
|
827
|
-
HTML_RE.test(chunk.fileName)
|
|
828
|
-
);
|
|
829
|
-
|
|
830
|
-
if (htmlFiles.length === 0) {
|
|
831
|
-
// Normal for SSR / library builds, which render HTML at request time
|
|
832
|
-
// - the manifest below is how those builds get their hashes.
|
|
833
|
-
logger.debug('No HTML files found in bundle');
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// Errors are intentionally not caught: a resource that cannot be
|
|
837
|
-
// hashed must fail the build rather than ship without integrity.
|
|
838
|
-
await Promise.all(
|
|
839
|
-
htmlFiles.map(async ([name, chunk]) => {
|
|
840
|
-
const originalContent = toText(chunk.source);
|
|
841
|
-
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
842
|
-
|
|
843
|
-
if (importmap) {
|
|
844
|
-
const moduleIntegrity = {};
|
|
845
|
-
const base = withTrailingSlash(config.base);
|
|
846
|
-
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
847
|
-
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
848
|
-
}
|
|
849
|
-
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
chunk.source = html;
|
|
1044
|
+
const plugins = config.plugins;
|
|
853
1045
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (manifest) {
|
|
861
|
-
this.emitFile({
|
|
862
|
-
type: 'asset',
|
|
863
|
-
fileName: MANIFEST_FILE_NAME,
|
|
864
|
-
source: JSON.stringify(hashes, null, 2)
|
|
865
|
-
});
|
|
866
|
-
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
867
|
-
}
|
|
868
|
-
};
|
|
1046
|
+
// The last one wins: if both names are present we must follow both
|
|
1047
|
+
let target = -1;
|
|
1048
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
1049
|
+
if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
|
|
1050
|
+
}
|
|
869
1051
|
|
|
870
|
-
|
|
871
|
-
p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
|
|
872
|
-
);
|
|
873
|
-
if (targets.length === 0) {
|
|
1052
|
+
if (target === -1) {
|
|
874
1053
|
throw new Error(
|
|
875
|
-
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to
|
|
1054
|
+
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
|
|
876
1055
|
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
877
1056
|
`Requires Vite 6.0.0 or higher.`
|
|
878
1057
|
)
|
|
879
1058
|
}
|
|
880
1059
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1060
|
+
const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
|
|
1061
|
+
if (self === -1) {
|
|
1062
|
+
// Only reachable when the plugin was not registered through Vite, as
|
|
1063
|
+
// in a unit test driving the hook directly. Hashes may then be taken a
|
|
1064
|
+
// step early, which the writeBundle drift check catches.
|
|
1065
|
+
logger.debug('Plugin not present in config.plugins; leaving hook order alone');
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (target > self) {
|
|
1070
|
+
// Removing ourselves shifts target down one, so inserting at `target`
|
|
1071
|
+
// lands immediately after it.
|
|
1072
|
+
const [me] = plugins.splice(self, 1);
|
|
1073
|
+
plugins.splice(target, 0, me);
|
|
1074
|
+
logger.debug(`Repositioned after ${plugins[target - 1].name}`);
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
async generateBundle(_, bundle) {
|
|
1079
|
+
// Computed before emitting anything so the manifest never hashes itself
|
|
1080
|
+
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
1081
|
+
|
|
1082
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
1083
|
+
([, chunk]) =>
|
|
1084
|
+
chunk.type === 'asset' &&
|
|
1085
|
+
HTML_RE.test(chunk.fileName)
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
if (htmlFiles.length === 0) {
|
|
1089
|
+
// Normal for SSR / library builds, which render HTML at request time
|
|
1090
|
+
// - the manifest below is how those builds get their hashes.
|
|
1091
|
+
logger.debug('No HTML files found in bundle');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Errors are intentionally not caught: a resource that cannot be
|
|
1095
|
+
// hashed must fail the build rather than ship without integrity.
|
|
1096
|
+
await Promise.all(
|
|
1097
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
1098
|
+
const originalContent = toText(chunk.source);
|
|
1099
|
+
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
1100
|
+
|
|
1101
|
+
if (importmap) {
|
|
1102
|
+
const moduleIntegrity = {};
|
|
1103
|
+
const base = withTrailingSlash(config.base);
|
|
1104
|
+
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
1105
|
+
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
1106
|
+
}
|
|
1107
|
+
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
chunk.source = html;
|
|
1111
|
+
|
|
1112
|
+
if (originalContent !== chunk.source) {
|
|
1113
|
+
logger.debug(`SRI attributes added to ${name}`);
|
|
1114
|
+
}
|
|
1115
|
+
})
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
if (manifest) {
|
|
1119
|
+
this.emitFile({
|
|
1120
|
+
type: 'asset',
|
|
1121
|
+
fileName: MANIFEST_FILE_NAME,
|
|
1122
|
+
source: JSON.stringify(hashes, null, 2)
|
|
1123
|
+
});
|
|
1124
|
+
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
895
1125
|
}
|
|
896
1126
|
}
|
|
897
1127
|
}
|