vite-plugin-sri4 4.1.0 → 4.2.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 +45 -4
- package/dist/index.cjs +215 -111
- package/dist/index.js +215 -111
- package/package.json +12 -8
- package/types/index.d.ts +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Zac
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
14
14
|
- [Plugin Options](#plugin-options)
|
|
15
15
|
- [Dynamic Routes](#dynamic-routes)
|
|
16
16
|
- [When SRI Actually Helps](#when-sri-actually-helps)
|
|
17
|
+
- [How It Attaches Hashes](#how-it-attaches-hashes)
|
|
17
18
|
- [Example Project](#example-project)
|
|
18
19
|
- [Best Practices](#best-practices)
|
|
19
20
|
- [Troubleshooting](#troubleshooting)
|
|
@@ -26,7 +27,9 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
26
27
|
- **Automatic SRI Generation:** Computes SRI hashes for assets (chunks and files) using a configurable algorithm (default is `sha384`).
|
|
27
28
|
- **HTML Injection:** Automatically injects `integrity` and `crossorigin` attributes into `<script>` and `<link>` tags in your HTML.
|
|
28
29
|
- **CORS Support Check:** For external resources, a CORS check is performed to verify access via `Access-Control-Allow-Origin`.
|
|
29
|
-
- **Bypass Domains:** Option to specify domains to bypass SRI injection.
|
|
30
|
+
- **Bypass Domains:** Option to specify domains to bypass SRI injection, plus a `skip-sri` attribute to opt out a single tag.
|
|
31
|
+
- **Public Directory Support:** Assets served verbatim from `publicDir` are hashed from disk, not just bundle outputs.
|
|
32
|
+
- **Zero Dependencies:** No runtime dependencies, and TypeScript definitions are included.
|
|
30
33
|
- **Missing Asset Handling:** Configurable warning suppression for missing assets.
|
|
31
34
|
- **Robust Content Support:** Handles various content types including strings, Buffer, and Uint8Array.
|
|
32
35
|
- **Dynamic Routes:** Optional import map integrity and an SRI manifest cover `import()`-loaded chunks and SSR builds, which have no build-time HTML tag to rewrite.
|
|
@@ -50,8 +53,10 @@ import sri from 'vite-plugin-sri4';
|
|
|
50
53
|
export default defineConfig({
|
|
51
54
|
plugins: [
|
|
52
55
|
sri({
|
|
53
|
-
// Optional.
|
|
56
|
+
// Optional. 'sha256' | 'sha384' | 'sha512'. Defaults to 'sha384'.
|
|
54
57
|
hashAlgorithm: 'sha384',
|
|
58
|
+
// Optional. 'anonymous' | 'use-credentials'. Defaults to 'anonymous'.
|
|
59
|
+
crossorigin: 'anonymous',
|
|
55
60
|
// Optional. Domains to bypass SRI injection.
|
|
56
61
|
bypassDomains: ['example.com'],
|
|
57
62
|
// Optional. Suppress warnings for missing assets.
|
|
@@ -85,11 +90,13 @@ Output:
|
|
|
85
90
|
## Plugin Options
|
|
86
91
|
|
|
87
92
|
* `hashAlgorithm` (string):
|
|
88
|
-
The hash algorithm used for computing SRI.
|
|
93
|
+
The hash algorithm used for computing SRI. One of `sha256`, `sha384` (default) or `sha512` — the only three the SRI spec defines. Anything else fails at startup rather than producing an attribute browsers silently reject.
|
|
94
|
+
* `crossorigin` (string):
|
|
95
|
+
Value for the injected `crossorigin` attribute: `anonymous` (default) or `use-credentials`. Use the latter for a CDN that requires cookies or HTTP auth. Tags that already declare a `crossorigin` are left alone.
|
|
89
96
|
* `bypassDomains` (Array<string>):
|
|
90
97
|
Array of domain names where SRI injection should be skipped. This allows external resources from specified domains to be excluded from SRI checks (for example, when they may not support CORS).
|
|
91
98
|
* `ignoreMissingAsset` (boolean):
|
|
92
|
-
When true,
|
|
99
|
+
When true, warns instead of failing the build for assets found in neither the bundle nor `publicDir`. Default is `false`, which fails the build rather than shipping a tag with no integrity.
|
|
93
100
|
* `logLevel` (string):
|
|
94
101
|
Log verbosity. One of `silent`, `error`, `warn`, `info`, `debug`. Default is `warn`. Use `debug` to see per-resource decisions during the build.
|
|
95
102
|
* `importmap` (boolean):
|
|
@@ -134,6 +141,40 @@ SRI is worth the most when your HTML and your assets have **different trust boun
|
|
|
134
141
|
|
|
135
142
|
If everything is served from a single origin, SRI buys much less than it appears to: an attacker who can rewrite `/assets/index-abc123.js` on your server can usually rewrite the `index.html` carrying its hash just as easily. It is not useless - it narrows some deploy and cache-layer mistakes - but for same-origin builds, a Content Security Policy and Vite's default hashed, immutable filenames do more for you than SRI does. Enable it because it is cheap, not because it closes the hole you think it closes.
|
|
136
143
|
|
|
144
|
+
### Skipping a Single Tag
|
|
145
|
+
|
|
146
|
+
`bypassDomains` only reaches external hosts. To exclude one specific element, add `skip-sri` to it. The attribute is stripped from the output:
|
|
147
|
+
|
|
148
|
+
```html
|
|
149
|
+
<script skip-sri src="/legacy.js"></script>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<!-- built output -->
|
|
154
|
+
<script src="/legacy.js"></script>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## How It Attaches Hashes
|
|
158
|
+
|
|
159
|
+
There are three places a Vite plugin can compute SRI hashes, and they are not equivalent. This one matters more than it looks, so it is worth writing down.
|
|
160
|
+
|
|
161
|
+
**In `transformIndexHtml`.** The obvious choice, and the one that reads best — you get the finished HTML and the bundle on the context. It produces wrong hashes for entry chunks. Vite's import-analysis plugin substitutes `__VITE_PRELOAD__` inside its own `generateBundle`, which runs *after* `transformIndexHtml`, so an entry chunk still reads `import("./route.js"), __VITE_PRELOAD__)` at that point while the written file reads `import("./route.js"), [])`. The hash describes bytes that never ship, and the browser rejects the file with no build error at all.
|
|
162
|
+
|
|
163
|
+
**In a plain `enforce: 'post'` `generateBundle`.** Same problem. Vite places its import-analysis plugin immediately after post user plugins, so a post hook is still one step too early.
|
|
164
|
+
|
|
165
|
+
**Where this plugin does it.** During `configResolved` it moves itself after that plugin in `config.plugins`, then works in an ordinary `generateBundle`. Measured on Vite 8.2.2, hashing the entry chunk:
|
|
166
|
+
|
|
167
|
+
| Hook | Entry chunk | Matches shipped file |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `transformIndexHtml` (post) | `io6MKsmc4G5y` | ✗ |
|
|
170
|
+
| `generateBundle` (post) | `io6MKsmc4G5y` | ✗ |
|
|
171
|
+
| after repositioning | `lvFyraHkqPN0` | ✓ |
|
|
172
|
+
| written file | `lvFyraHkqPN0` | — |
|
|
173
|
+
|
|
174
|
+
Only the entry chunk is affected, so a build without a dynamic import will not reveal the difference. As a second safeguard, every hashed file is re-hashed in `writeBundle` and the build fails if anything changed after the hash was taken.
|
|
175
|
+
|
|
176
|
+
This ordering constraint was first identified by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which this plugin began as a fork of. Beyond it, this plugin adds `crossorigin` injection, a CORS pre-check with timeouts and retries for external resources, import map and manifest output for dynamically imported routes, `publicDir` resolution, and the drift check above.
|
|
177
|
+
|
|
137
178
|
## Example Project
|
|
138
179
|
|
|
139
180
|
The plugin includes an example project in the `example` directory that demonstrates its usage with a simple Vite application. To try it:
|
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,6 +67,8 @@ 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
|
/**
|
|
@@ -202,6 +204,10 @@ function bundleSource(item) {
|
|
|
202
204
|
return item.type === 'chunk' ? item.code : item.source
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
// The only algorithms the SRI spec defines. Browsers reject anything else,
|
|
208
|
+
// which blocks the resource with no build-time error at all.
|
|
209
|
+
const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
|
210
|
+
|
|
205
211
|
/**
|
|
206
212
|
* Compute an SRI string for a source that may be a string, Buffer or Uint8Array
|
|
207
213
|
*/
|
|
@@ -276,6 +282,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
|
|
|
276
282
|
return candidates[0]
|
|
277
283
|
}
|
|
278
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Read an asset that lives in `publicDir` rather than the bundle.
|
|
287
|
+
*
|
|
288
|
+
* Files copied verbatim from `public/` never appear as bundle entries, so
|
|
289
|
+
* without this a perfectly normal `<script src="/sw.js">` fails the build.
|
|
290
|
+
* Returns null rather than throwing so the caller keeps its own missing-asset
|
|
291
|
+
* policy.
|
|
292
|
+
*/
|
|
293
|
+
async function readPublicAsset(config, bundleKey, logger) {
|
|
294
|
+
const publicDir = config.publicDir;
|
|
295
|
+
if (!publicDir) return null
|
|
296
|
+
|
|
297
|
+
// Bundle keys come from URLs, which may be percent-encoded
|
|
298
|
+
let decoded;
|
|
299
|
+
try {
|
|
300
|
+
decoded = decodeURIComponent(bundleKey);
|
|
301
|
+
} catch {
|
|
302
|
+
decoded = bundleKey;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const filePath = path.resolve(publicDir, decoded);
|
|
306
|
+
|
|
307
|
+
// A URL must never reach outside publicDir, however it is spelled
|
|
308
|
+
const relative = path.relative(publicDir, filePath);
|
|
309
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
310
|
+
if (logger) {
|
|
311
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
312
|
+
}
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const source = await promises.readFile(filePath);
|
|
318
|
+
if (logger) {
|
|
319
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
320
|
+
}
|
|
321
|
+
return source
|
|
322
|
+
} catch {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
279
327
|
/**
|
|
280
328
|
* Calculate SRI integrity hash for a given resource
|
|
281
329
|
*/
|
|
@@ -337,13 +385,23 @@ async function calculateIntegrity(
|
|
|
337
385
|
}
|
|
338
386
|
bundleFileName = possibleMatch;
|
|
339
387
|
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
388
|
} else {
|
|
346
|
-
|
|
389
|
+
// Not a build output - it may still be a file copied from publicDir
|
|
390
|
+
source = await readPublicAsset(config, bundleKey, logger);
|
|
391
|
+
|
|
392
|
+
if (!source) {
|
|
393
|
+
if (ignoreMissingAsset) {
|
|
394
|
+
if (logger) {
|
|
395
|
+
logger.warn(
|
|
396
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
|
|
403
|
+
)
|
|
404
|
+
}
|
|
347
405
|
}
|
|
348
406
|
} else {
|
|
349
407
|
bundleFileName = bundleKey;
|
|
@@ -431,7 +489,15 @@ function insertOffset(tag, endOffset) {
|
|
|
431
489
|
}
|
|
432
490
|
|
|
433
491
|
/**
|
|
434
|
-
*
|
|
492
|
+
* Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
|
|
493
|
+
* attribute is stripped so it does not ship to the browser. More granular than
|
|
494
|
+
* bypassDomains, which only reaches external hosts.
|
|
495
|
+
*/
|
|
496
|
+
const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Process a single match into a text edit: { start, end, content }. An
|
|
500
|
+
* insertion has start === end; a removal has empty content.
|
|
435
501
|
*/
|
|
436
502
|
async function processMatch(
|
|
437
503
|
match,
|
|
@@ -444,6 +510,20 @@ async function processMatch(
|
|
|
444
510
|
logger
|
|
445
511
|
) {
|
|
446
512
|
const tag = match[0];
|
|
513
|
+
|
|
514
|
+
const skip = SKIP_SRI_ATTR_RE.exec(tag);
|
|
515
|
+
if (skip) {
|
|
516
|
+
return {
|
|
517
|
+
start: match.index + skip.index,
|
|
518
|
+
end: match.index + skip.index + skip[0].length,
|
|
519
|
+
content: '',
|
|
520
|
+
url: pattern.getUrl(tag),
|
|
521
|
+
skipped: true
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Nothing to do for a tag that already carries its own hash, and computing
|
|
526
|
+
// one anyway would register it for the writeBundle drift check
|
|
447
527
|
if (INTEGRITY_ATTR_RE.test(tag)) return null
|
|
448
528
|
|
|
449
529
|
const url = pattern.getUrl(tag);
|
|
@@ -459,15 +539,15 @@ async function processMatch(
|
|
|
459
539
|
logger
|
|
460
540
|
);
|
|
461
541
|
|
|
462
|
-
if (integrity)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
url // For logging
|
|
468
|
-
}
|
|
542
|
+
if (!integrity) return null
|
|
543
|
+
|
|
544
|
+
let content = ` integrity="${integrity}"`;
|
|
545
|
+
if (!CROSSORIGIN_ATTR_RE.test(tag)) {
|
|
546
|
+
content += ` crossorigin="${options.crossorigin}"`;
|
|
469
547
|
}
|
|
470
|
-
|
|
548
|
+
|
|
549
|
+
const at = match.index + insertOffset(tag, pattern.endOffset);
|
|
550
|
+
return { start: at, end: at, content, url }
|
|
471
551
|
}
|
|
472
552
|
|
|
473
553
|
/**
|
|
@@ -538,16 +618,12 @@ async function collectIntegrityChanges(
|
|
|
538
618
|
* Apply integrity changes to HTML content
|
|
539
619
|
*/
|
|
540
620
|
function applyIntegrityChanges(html, changes, logger) {
|
|
541
|
-
//
|
|
542
|
-
changes.sort((a, b) => b.
|
|
621
|
+
// Back to front, so earlier offsets stay valid as the string is edited
|
|
622
|
+
changes.sort((a, b) => b.start - a.start);
|
|
543
623
|
|
|
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}`);
|
|
624
|
+
for (const { start, end, content, url, skipped } of changes) {
|
|
625
|
+
html = html.slice(0, start) + content + html.slice(end);
|
|
626
|
+
logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
|
|
551
627
|
}
|
|
552
628
|
|
|
553
629
|
return html
|
|
@@ -704,23 +780,27 @@ class Logger {
|
|
|
704
780
|
}
|
|
705
781
|
}
|
|
706
782
|
|
|
707
|
-
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
|
|
708
|
-
//
|
|
783
|
+
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
|
784
|
+
// `native:import-analysis-build`.
|
|
785
|
+
//
|
|
786
|
+
// Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
|
|
787
|
+
// chunks inside their own generateBundle, and Vite places them immediately
|
|
788
|
+
// AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
|
|
789
|
+
// is at index 25 and the analysis plugin at 26 - so by default we would hash
|
|
790
|
+
// an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
|
|
791
|
+
// the written file contains `import("./x.js"),[])`.
|
|
709
792
|
//
|
|
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.
|
|
793
|
+
// The fix is to move THIS plugin one place later, not to rewrite someone
|
|
794
|
+
// else's hook. `config.plugins` is a plain, unfrozen array at configResolved
|
|
795
|
+
// time and Rollup reads it afterwards, so repositioning takes effect.
|
|
796
|
+
// `test/sri.test.js > injects integrity matching the actual emitted bytes` is
|
|
797
|
+
// the pin, and the writeBundle drift check is the second net.
|
|
719
798
|
const VITE_INTERNAL_ANALYSIS_PLUGINS = [
|
|
720
799
|
'vite:build-import-analysis',
|
|
721
800
|
'native:import-analysis-build'
|
|
722
801
|
];
|
|
723
802
|
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
803
|
+
const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
|
|
724
804
|
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
725
805
|
const MANIFEST_FILE_NAME = 'sri-manifest.json';
|
|
726
806
|
const HTML_RE = /\.html?$/;
|
|
@@ -748,16 +828,39 @@ function hashBundle(bundle, hashAlgorithm) {
|
|
|
748
828
|
return hashes
|
|
749
829
|
}
|
|
750
830
|
|
|
831
|
+
/**
|
|
832
|
+
* Reject configuration that would build cleanly and then fail in the browser.
|
|
833
|
+
*/
|
|
834
|
+
function validateOptions(hashAlgorithm, crossorigin) {
|
|
835
|
+
if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
|
|
838
|
+
`The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
|
|
839
|
+
`anything else, so the build would succeed and the resource would be blocked.`
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
|
|
846
|
+
`got "${crossorigin}"`
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
751
851
|
function sri(options = {}) {
|
|
752
852
|
const {
|
|
753
853
|
ignoreMissingAsset = false,
|
|
754
854
|
bypassDomains = [],
|
|
755
855
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
856
|
+
crossorigin = 'anonymous',
|
|
756
857
|
logLevel = 'warn',
|
|
757
858
|
manifest = false,
|
|
758
859
|
importmap = false
|
|
759
860
|
} = options;
|
|
760
861
|
|
|
862
|
+
validateOptions(hashAlgorithm, crossorigin);
|
|
863
|
+
|
|
761
864
|
// Create cache manager and logger instances for this plugin instance
|
|
762
865
|
const cacheManager = new CacheManager();
|
|
763
866
|
const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
|
|
@@ -765,6 +868,9 @@ function sri(options = {}) {
|
|
|
765
868
|
// bundle fileName -> the integrity we injected, re-checked in writeBundle
|
|
766
869
|
const hashedAssets = new Map();
|
|
767
870
|
|
|
871
|
+
let config;
|
|
872
|
+
let transformer;
|
|
873
|
+
|
|
768
874
|
return {
|
|
769
875
|
name: DEFAULT_PLUGIN_NAME,
|
|
770
876
|
enforce: 'post',
|
|
@@ -803,99 +909,97 @@ function sri(options = {}) {
|
|
|
803
909
|
hashedAssets.clear();
|
|
804
910
|
},
|
|
805
911
|
|
|
806
|
-
configResolved(
|
|
807
|
-
|
|
912
|
+
configResolved(resolvedConfig) {
|
|
913
|
+
config = resolvedConfig;
|
|
914
|
+
transformer = createTransformer({
|
|
808
915
|
ignoreMissingAsset,
|
|
809
916
|
bypassDomains,
|
|
810
917
|
hashAlgorithm,
|
|
918
|
+
crossorigin,
|
|
811
919
|
hashedAssets
|
|
812
920
|
}, config, cacheManager, logger);
|
|
813
921
|
|
|
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();
|
|
922
|
+
const plugins = config.plugins;
|
|
820
923
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
924
|
+
// The last one wins: if both names are present we must follow both
|
|
925
|
+
let target = -1;
|
|
926
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
927
|
+
if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
|
|
928
|
+
}
|
|
824
929
|
|
|
825
|
-
|
|
826
|
-
|
|
930
|
+
if (target === -1) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
|
|
933
|
+
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
934
|
+
`Requires Vite 6.0.0 or higher.`
|
|
935
|
+
)
|
|
936
|
+
}
|
|
827
937
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
938
|
+
const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
|
|
939
|
+
if (self === -1) {
|
|
940
|
+
// Only reachable when the plugin was not registered through Vite, as
|
|
941
|
+
// in a unit test driving the hook directly. Hashes may then be taken a
|
|
942
|
+
// step early, which the writeBundle drift check catches.
|
|
943
|
+
logger.debug('Plugin not present in config.plugins; leaving hook order alone');
|
|
944
|
+
return
|
|
945
|
+
}
|
|
833
946
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
947
|
+
if (target > self) {
|
|
948
|
+
// Removing ourselves shifts target down one, so inserting at `target`
|
|
949
|
+
// lands immediately after it.
|
|
950
|
+
const [me] = plugins.splice(self, 1);
|
|
951
|
+
plugins.splice(target, 0, me);
|
|
952
|
+
logger.debug(`Repositioned after ${plugins[target - 1].name}`);
|
|
953
|
+
}
|
|
954
|
+
},
|
|
839
955
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
}
|
|
956
|
+
async generateBundle(_, bundle) {
|
|
957
|
+
// Computed before emitting anything so the manifest never hashes itself
|
|
958
|
+
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
959
|
+
|
|
960
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
961
|
+
([, chunk]) =>
|
|
962
|
+
chunk.type === 'asset' &&
|
|
963
|
+
HTML_RE.test(chunk.fileName)
|
|
964
|
+
);
|
|
855
965
|
|
|
856
|
-
|
|
966
|
+
if (htmlFiles.length === 0) {
|
|
967
|
+
// Normal for SSR / library builds, which render HTML at request time
|
|
968
|
+
// - the manifest below is how those builds get their hashes.
|
|
969
|
+
logger.debug('No HTML files found in bundle');
|
|
970
|
+
}
|
|
857
971
|
|
|
858
|
-
|
|
859
|
-
|
|
972
|
+
// Errors are intentionally not caught: a resource that cannot be
|
|
973
|
+
// hashed must fail the build rather than ship without integrity.
|
|
974
|
+
await Promise.all(
|
|
975
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
976
|
+
const originalContent = toText(chunk.source);
|
|
977
|
+
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
978
|
+
|
|
979
|
+
if (importmap) {
|
|
980
|
+
const moduleIntegrity = {};
|
|
981
|
+
const base = withTrailingSlash(config.base);
|
|
982
|
+
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
983
|
+
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
860
984
|
}
|
|
861
|
-
|
|
862
|
-
|
|
985
|
+
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
986
|
+
}
|
|
863
987
|
|
|
864
|
-
|
|
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
|
-
};
|
|
988
|
+
chunk.source = html;
|
|
873
989
|
|
|
874
|
-
|
|
875
|
-
|
|
990
|
+
if (originalContent !== chunk.source) {
|
|
991
|
+
logger.debug(`SRI attributes added to ${name}`);
|
|
992
|
+
}
|
|
993
|
+
})
|
|
876
994
|
);
|
|
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
|
-
)
|
|
883
|
-
}
|
|
884
995
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
}
|
|
996
|
+
if (manifest) {
|
|
997
|
+
this.emitFile({
|
|
998
|
+
type: 'asset',
|
|
999
|
+
fileName: MANIFEST_FILE_NAME,
|
|
1000
|
+
source: JSON.stringify(hashes, null, 2)
|
|
1001
|
+
});
|
|
1002
|
+
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
899
1003
|
}
|
|
900
1004
|
}
|
|
901
1005
|
}
|
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,6 +63,8 @@ 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
|
/**
|
|
@@ -198,6 +200,10 @@ function bundleSource(item) {
|
|
|
198
200
|
return item.type === 'chunk' ? item.code : item.source
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
// The only algorithms the SRI spec defines. Browsers reject anything else,
|
|
204
|
+
// which blocks the resource with no build-time error at all.
|
|
205
|
+
const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
|
206
|
+
|
|
201
207
|
/**
|
|
202
208
|
* Compute an SRI string for a source that may be a string, Buffer or Uint8Array
|
|
203
209
|
*/
|
|
@@ -272,6 +278,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
|
|
|
272
278
|
return candidates[0]
|
|
273
279
|
}
|
|
274
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Read an asset that lives in `publicDir` rather than the bundle.
|
|
283
|
+
*
|
|
284
|
+
* Files copied verbatim from `public/` never appear as bundle entries, so
|
|
285
|
+
* without this a perfectly normal `<script src="/sw.js">` fails the build.
|
|
286
|
+
* Returns null rather than throwing so the caller keeps its own missing-asset
|
|
287
|
+
* policy.
|
|
288
|
+
*/
|
|
289
|
+
async function readPublicAsset(config, bundleKey, logger) {
|
|
290
|
+
const publicDir = config.publicDir;
|
|
291
|
+
if (!publicDir) return null
|
|
292
|
+
|
|
293
|
+
// Bundle keys come from URLs, which may be percent-encoded
|
|
294
|
+
let decoded;
|
|
295
|
+
try {
|
|
296
|
+
decoded = decodeURIComponent(bundleKey);
|
|
297
|
+
} catch {
|
|
298
|
+
decoded = bundleKey;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const filePath = path.resolve(publicDir, decoded);
|
|
302
|
+
|
|
303
|
+
// A URL must never reach outside publicDir, however it is spelled
|
|
304
|
+
const relative = path.relative(publicDir, filePath);
|
|
305
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
306
|
+
if (logger) {
|
|
307
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const source = await readFile(filePath);
|
|
314
|
+
if (logger) {
|
|
315
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
316
|
+
}
|
|
317
|
+
return source
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
275
323
|
/**
|
|
276
324
|
* Calculate SRI integrity hash for a given resource
|
|
277
325
|
*/
|
|
@@ -333,13 +381,23 @@ async function calculateIntegrity(
|
|
|
333
381
|
}
|
|
334
382
|
bundleFileName = possibleMatch;
|
|
335
383
|
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
384
|
} else {
|
|
342
|
-
|
|
385
|
+
// Not a build output - it may still be a file copied from publicDir
|
|
386
|
+
source = await readPublicAsset(config, bundleKey, logger);
|
|
387
|
+
|
|
388
|
+
if (!source) {
|
|
389
|
+
if (ignoreMissingAsset) {
|
|
390
|
+
if (logger) {
|
|
391
|
+
logger.warn(
|
|
392
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
|
|
399
|
+
)
|
|
400
|
+
}
|
|
343
401
|
}
|
|
344
402
|
} else {
|
|
345
403
|
bundleFileName = bundleKey;
|
|
@@ -427,7 +485,15 @@ function insertOffset(tag, endOffset) {
|
|
|
427
485
|
}
|
|
428
486
|
|
|
429
487
|
/**
|
|
430
|
-
*
|
|
488
|
+
* Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
|
|
489
|
+
* attribute is stripped so it does not ship to the browser. More granular than
|
|
490
|
+
* bypassDomains, which only reaches external hosts.
|
|
491
|
+
*/
|
|
492
|
+
const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Process a single match into a text edit: { start, end, content }. An
|
|
496
|
+
* insertion has start === end; a removal has empty content.
|
|
431
497
|
*/
|
|
432
498
|
async function processMatch(
|
|
433
499
|
match,
|
|
@@ -440,6 +506,20 @@ async function processMatch(
|
|
|
440
506
|
logger
|
|
441
507
|
) {
|
|
442
508
|
const tag = match[0];
|
|
509
|
+
|
|
510
|
+
const skip = SKIP_SRI_ATTR_RE.exec(tag);
|
|
511
|
+
if (skip) {
|
|
512
|
+
return {
|
|
513
|
+
start: match.index + skip.index,
|
|
514
|
+
end: match.index + skip.index + skip[0].length,
|
|
515
|
+
content: '',
|
|
516
|
+
url: pattern.getUrl(tag),
|
|
517
|
+
skipped: true
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Nothing to do for a tag that already carries its own hash, and computing
|
|
522
|
+
// one anyway would register it for the writeBundle drift check
|
|
443
523
|
if (INTEGRITY_ATTR_RE.test(tag)) return null
|
|
444
524
|
|
|
445
525
|
const url = pattern.getUrl(tag);
|
|
@@ -455,15 +535,15 @@ async function processMatch(
|
|
|
455
535
|
logger
|
|
456
536
|
);
|
|
457
537
|
|
|
458
|
-
if (integrity)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
url // For logging
|
|
464
|
-
}
|
|
538
|
+
if (!integrity) return null
|
|
539
|
+
|
|
540
|
+
let content = ` integrity="${integrity}"`;
|
|
541
|
+
if (!CROSSORIGIN_ATTR_RE.test(tag)) {
|
|
542
|
+
content += ` crossorigin="${options.crossorigin}"`;
|
|
465
543
|
}
|
|
466
|
-
|
|
544
|
+
|
|
545
|
+
const at = match.index + insertOffset(tag, pattern.endOffset);
|
|
546
|
+
return { start: at, end: at, content, url }
|
|
467
547
|
}
|
|
468
548
|
|
|
469
549
|
/**
|
|
@@ -534,16 +614,12 @@ async function collectIntegrityChanges(
|
|
|
534
614
|
* Apply integrity changes to HTML content
|
|
535
615
|
*/
|
|
536
616
|
function applyIntegrityChanges(html, changes, logger) {
|
|
537
|
-
//
|
|
538
|
-
changes.sort((a, b) => b.
|
|
617
|
+
// Back to front, so earlier offsets stay valid as the string is edited
|
|
618
|
+
changes.sort((a, b) => b.start - a.start);
|
|
539
619
|
|
|
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}`);
|
|
620
|
+
for (const { start, end, content, url, skipped } of changes) {
|
|
621
|
+
html = html.slice(0, start) + content + html.slice(end);
|
|
622
|
+
logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
|
|
547
623
|
}
|
|
548
624
|
|
|
549
625
|
return html
|
|
@@ -700,23 +776,27 @@ class Logger {
|
|
|
700
776
|
}
|
|
701
777
|
}
|
|
702
778
|
|
|
703
|
-
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
|
|
704
|
-
//
|
|
779
|
+
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
|
780
|
+
// `native:import-analysis-build`.
|
|
781
|
+
//
|
|
782
|
+
// Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
|
|
783
|
+
// chunks inside their own generateBundle, and Vite places them immediately
|
|
784
|
+
// AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
|
|
785
|
+
// is at index 25 and the analysis plugin at 26 - so by default we would hash
|
|
786
|
+
// an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
|
|
787
|
+
// the written file contains `import("./x.js"),[])`.
|
|
705
788
|
//
|
|
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.
|
|
789
|
+
// The fix is to move THIS plugin one place later, not to rewrite someone
|
|
790
|
+
// else's hook. `config.plugins` is a plain, unfrozen array at configResolved
|
|
791
|
+
// time and Rollup reads it afterwards, so repositioning takes effect.
|
|
792
|
+
// `test/sri.test.js > injects integrity matching the actual emitted bytes` is
|
|
793
|
+
// the pin, and the writeBundle drift check is the second net.
|
|
715
794
|
const VITE_INTERNAL_ANALYSIS_PLUGINS = [
|
|
716
795
|
'vite:build-import-analysis',
|
|
717
796
|
'native:import-analysis-build'
|
|
718
797
|
];
|
|
719
798
|
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
799
|
+
const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
|
|
720
800
|
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
721
801
|
const MANIFEST_FILE_NAME = 'sri-manifest.json';
|
|
722
802
|
const HTML_RE = /\.html?$/;
|
|
@@ -744,16 +824,39 @@ function hashBundle(bundle, hashAlgorithm) {
|
|
|
744
824
|
return hashes
|
|
745
825
|
}
|
|
746
826
|
|
|
827
|
+
/**
|
|
828
|
+
* Reject configuration that would build cleanly and then fail in the browser.
|
|
829
|
+
*/
|
|
830
|
+
function validateOptions(hashAlgorithm, crossorigin) {
|
|
831
|
+
if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
`[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
|
|
834
|
+
`The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
|
|
835
|
+
`anything else, so the build would succeed and the resource would be blocked.`
|
|
836
|
+
)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
|
|
842
|
+
`got "${crossorigin}"`
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
747
847
|
function sri(options = {}) {
|
|
748
848
|
const {
|
|
749
849
|
ignoreMissingAsset = false,
|
|
750
850
|
bypassDomains = [],
|
|
751
851
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
852
|
+
crossorigin = 'anonymous',
|
|
752
853
|
logLevel = 'warn',
|
|
753
854
|
manifest = false,
|
|
754
855
|
importmap = false
|
|
755
856
|
} = options;
|
|
756
857
|
|
|
858
|
+
validateOptions(hashAlgorithm, crossorigin);
|
|
859
|
+
|
|
757
860
|
// Create cache manager and logger instances for this plugin instance
|
|
758
861
|
const cacheManager = new CacheManager();
|
|
759
862
|
const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
|
|
@@ -761,6 +864,9 @@ function sri(options = {}) {
|
|
|
761
864
|
// bundle fileName -> the integrity we injected, re-checked in writeBundle
|
|
762
865
|
const hashedAssets = new Map();
|
|
763
866
|
|
|
867
|
+
let config;
|
|
868
|
+
let transformer;
|
|
869
|
+
|
|
764
870
|
return {
|
|
765
871
|
name: DEFAULT_PLUGIN_NAME,
|
|
766
872
|
enforce: 'post',
|
|
@@ -799,99 +905,97 @@ function sri(options = {}) {
|
|
|
799
905
|
hashedAssets.clear();
|
|
800
906
|
},
|
|
801
907
|
|
|
802
|
-
configResolved(
|
|
803
|
-
|
|
908
|
+
configResolved(resolvedConfig) {
|
|
909
|
+
config = resolvedConfig;
|
|
910
|
+
transformer = createTransformer({
|
|
804
911
|
ignoreMissingAsset,
|
|
805
912
|
bypassDomains,
|
|
806
913
|
hashAlgorithm,
|
|
914
|
+
crossorigin,
|
|
807
915
|
hashedAssets
|
|
808
916
|
}, config, cacheManager, logger);
|
|
809
917
|
|
|
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();
|
|
918
|
+
const plugins = config.plugins;
|
|
816
919
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
920
|
+
// The last one wins: if both names are present we must follow both
|
|
921
|
+
let target = -1;
|
|
922
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
923
|
+
if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
|
|
924
|
+
}
|
|
820
925
|
|
|
821
|
-
|
|
822
|
-
|
|
926
|
+
if (target === -1) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
|
|
929
|
+
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
930
|
+
`Requires Vite 6.0.0 or higher.`
|
|
931
|
+
)
|
|
932
|
+
}
|
|
823
933
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
934
|
+
const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
|
|
935
|
+
if (self === -1) {
|
|
936
|
+
// Only reachable when the plugin was not registered through Vite, as
|
|
937
|
+
// in a unit test driving the hook directly. Hashes may then be taken a
|
|
938
|
+
// step early, which the writeBundle drift check catches.
|
|
939
|
+
logger.debug('Plugin not present in config.plugins; leaving hook order alone');
|
|
940
|
+
return
|
|
941
|
+
}
|
|
829
942
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
943
|
+
if (target > self) {
|
|
944
|
+
// Removing ourselves shifts target down one, so inserting at `target`
|
|
945
|
+
// lands immediately after it.
|
|
946
|
+
const [me] = plugins.splice(self, 1);
|
|
947
|
+
plugins.splice(target, 0, me);
|
|
948
|
+
logger.debug(`Repositioned after ${plugins[target - 1].name}`);
|
|
949
|
+
}
|
|
950
|
+
},
|
|
835
951
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
}
|
|
952
|
+
async generateBundle(_, bundle) {
|
|
953
|
+
// Computed before emitting anything so the manifest never hashes itself
|
|
954
|
+
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
955
|
+
|
|
956
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
957
|
+
([, chunk]) =>
|
|
958
|
+
chunk.type === 'asset' &&
|
|
959
|
+
HTML_RE.test(chunk.fileName)
|
|
960
|
+
);
|
|
851
961
|
|
|
852
|
-
|
|
962
|
+
if (htmlFiles.length === 0) {
|
|
963
|
+
// Normal for SSR / library builds, which render HTML at request time
|
|
964
|
+
// - the manifest below is how those builds get their hashes.
|
|
965
|
+
logger.debug('No HTML files found in bundle');
|
|
966
|
+
}
|
|
853
967
|
|
|
854
|
-
|
|
855
|
-
|
|
968
|
+
// Errors are intentionally not caught: a resource that cannot be
|
|
969
|
+
// hashed must fail the build rather than ship without integrity.
|
|
970
|
+
await Promise.all(
|
|
971
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
972
|
+
const originalContent = toText(chunk.source);
|
|
973
|
+
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
974
|
+
|
|
975
|
+
if (importmap) {
|
|
976
|
+
const moduleIntegrity = {};
|
|
977
|
+
const base = withTrailingSlash(config.base);
|
|
978
|
+
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
979
|
+
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
856
980
|
}
|
|
857
|
-
|
|
858
|
-
|
|
981
|
+
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
982
|
+
}
|
|
859
983
|
|
|
860
|
-
|
|
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
|
-
};
|
|
984
|
+
chunk.source = html;
|
|
869
985
|
|
|
870
|
-
|
|
871
|
-
|
|
986
|
+
if (originalContent !== chunk.source) {
|
|
987
|
+
logger.debug(`SRI attributes added to ${name}`);
|
|
988
|
+
}
|
|
989
|
+
})
|
|
872
990
|
);
|
|
873
|
-
if (targets.length === 0) {
|
|
874
|
-
throw new Error(
|
|
875
|
-
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
|
|
876
|
-
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
877
|
-
`Requires Vite 6.0.0 or higher.`
|
|
878
|
-
)
|
|
879
|
-
}
|
|
880
991
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
} else if (typeof plugin.generateBundle === 'function') {
|
|
889
|
-
const originalHandler = plugin.generateBundle;
|
|
890
|
-
plugin.generateBundle = async function(...args) {
|
|
891
|
-
await originalHandler.apply(this, args);
|
|
892
|
-
await generateBundle.apply(this, args);
|
|
893
|
-
};
|
|
894
|
-
}
|
|
992
|
+
if (manifest) {
|
|
993
|
+
this.emitFile({
|
|
994
|
+
type: 'asset',
|
|
995
|
+
fileName: MANIFEST_FILE_NAME,
|
|
996
|
+
source: JSON.stringify(hashes, null, 2)
|
|
997
|
+
});
|
|
998
|
+
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
895
999
|
}
|
|
896
1000
|
}
|
|
897
1001
|
}
|
package/package.json
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-sri4",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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",
|
|
7
7
|
"module": "./dist/index.js",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
10
11
|
"import": "./dist/index.js",
|
|
11
12
|
"require": "./dist/index.cjs"
|
|
12
13
|
}
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
|
-
"dist"
|
|
16
|
+
"dist",
|
|
17
|
+
"types",
|
|
18
|
+
"LICENSE"
|
|
16
19
|
],
|
|
17
20
|
"scripts": {
|
|
18
21
|
"build": "rollup -c",
|
|
19
22
|
"dev": "rollup -c -w",
|
|
20
|
-
"lint": "oxlint src test example rollup.config.mjs vitest.config.js",
|
|
23
|
+
"lint": "oxlint src test types example rollup.config.mjs vitest.config.js",
|
|
21
24
|
"test": "vitest run",
|
|
22
25
|
"test:watch": "vitest",
|
|
23
26
|
"test:coverage": "vitest run --coverage",
|
|
24
27
|
"prepublishOnly": "npm run build"
|
|
25
28
|
},
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"cross-fetch": "^4.1.0"
|
|
28
|
-
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
|
31
31
|
},
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"subresource integrity"
|
|
46
46
|
],
|
|
47
47
|
"author": "Zac",
|
|
48
|
-
"license": "
|
|
48
|
+
"license": "MIT",
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
@@ -56,5 +56,9 @@
|
|
|
56
56
|
"bugs": {
|
|
57
57
|
"url": "https://github.com/7a6163/vite-plugin-sri4/issues"
|
|
58
58
|
},
|
|
59
|
-
"homepage": "https://github.com/7a6163/vite-plugin-sri4#readme"
|
|
59
|
+
"homepage": "https://github.com/7a6163/vite-plugin-sri4#readme",
|
|
60
|
+
"types": "./types/index.d.ts",
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=18"
|
|
63
|
+
}
|
|
60
64
|
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
|
|
3
|
+
/** The only algorithms the SRI spec defines; browsers reject anything else. */
|
|
4
|
+
export type SriHashAlgorithm = 'sha256' | 'sha384' | 'sha512'
|
|
5
|
+
|
|
6
|
+
export interface SriOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Hash algorithm used to compute the integrity value.
|
|
9
|
+
* @default 'sha384'
|
|
10
|
+
*/
|
|
11
|
+
hashAlgorithm?: SriHashAlgorithm
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Value for the injected `crossorigin` attribute. Tags that already declare
|
|
15
|
+
* one are left alone.
|
|
16
|
+
* @default 'anonymous'
|
|
17
|
+
*/
|
|
18
|
+
crossorigin?: 'anonymous' | 'use-credentials'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hostnames to leave untouched. Matches the host itself and its subdomains.
|
|
22
|
+
* Only applies to external (http/https) URLs; use the `skip-sri` attribute
|
|
23
|
+
* on a tag to opt a single element out.
|
|
24
|
+
* @default []
|
|
25
|
+
*/
|
|
26
|
+
bypassDomains?: string[]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Warn instead of failing the build when an asset resolves to neither a
|
|
30
|
+
* bundle entry nor a file in `publicDir`.
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
ignoreMissingAsset?: boolean
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Log verbosity.
|
|
37
|
+
* @default 'warn'
|
|
38
|
+
*/
|
|
39
|
+
logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Inject `<script type="importmap">` carrying an `integrity` map for every
|
|
43
|
+
* JS chunk, covering modules loaded at runtime by `import()` that have no
|
|
44
|
+
* build-time tag to rewrite.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
importmap?: boolean
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit `sri-manifest.json` mapping every non-HTML output file to its hash,
|
|
51
|
+
* for servers that render HTML per request.
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
manifest?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare function sri(options?: SriOptions): Plugin
|
|
58
|
+
|
|
59
|
+
export default sri
|
|
60
|
+
export { sri }
|