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