vite-plugin-sri4 1.8.6 → 1.9.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/dist/index.cjs +138 -221
- package/dist/index.js +138 -221
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -1,270 +1,187 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
var path = require('path');
|
|
4
5
|
var fetch = require('cross-fetch');
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
8
|
+
const HTML_PATTERNS = {
|
|
9
|
+
script: {
|
|
10
|
+
regex: /<script[^<>]*['"]*src['"]*=['"]*([^ '"]+)['"]*[^<>]*><\/script>/g,
|
|
11
|
+
endOffset: 10
|
|
12
|
+
},
|
|
13
|
+
stylesheet: {
|
|
14
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*stylesheet['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
15
|
+
endOffset: 1
|
|
16
|
+
},
|
|
17
|
+
modulepreload: {
|
|
18
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*modulepreload['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
19
|
+
endOffset: 1
|
|
20
|
+
}
|
|
14
21
|
};
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
if (options.debug) {
|
|
18
|
-
console.log(`${LOG_PREFIX} ${message}`);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
23
|
+
const urlSupportCache = new Map();
|
|
21
24
|
|
|
22
|
-
function
|
|
25
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
26
|
+
if (!url.startsWith('http')) return false
|
|
23
27
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error('Invalid content type');
|
|
31
|
-
}
|
|
32
|
-
return `${algorithm}-${hash.digest('base64')}`;
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
35
|
-
return null;
|
|
28
|
+
const urlObj = new URL(url);
|
|
29
|
+
return bypassDomains.some(domain =>
|
|
30
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
31
|
+
)
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
async function
|
|
37
|
+
async function checkResourceSupport(url) {
|
|
38
|
+
if (urlSupportCache.has(url)) {
|
|
39
|
+
return urlSupportCache.get(url)
|
|
40
|
+
}
|
|
41
|
+
|
|
40
42
|
try {
|
|
41
43
|
const response = await fetch(url, {
|
|
42
|
-
method: 'HEAD'
|
|
44
|
+
method: 'HEAD',
|
|
45
|
+
timeout: 5000
|
|
43
46
|
});
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return false;
|
|
47
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
48
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
49
|
+
urlSupportCache.set(url, isSupported);
|
|
50
|
+
return isSupported
|
|
51
|
+
} catch {
|
|
52
|
+
urlSupportCache.set(url, false);
|
|
53
|
+
return false
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
function
|
|
56
|
-
if (!bypassDomains.length) return false;
|
|
57
|
+
async function fetchResource(url) {
|
|
57
58
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} else if (hostname.startsWith('//')) {
|
|
65
|
-
hostname = hostname.slice(2);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
hostname = hostname.split('/')[0];
|
|
69
|
-
|
|
70
|
-
hostname = hostname.split(':')[0];
|
|
71
|
-
|
|
72
|
-
return bypassDomains.some(domain =>
|
|
73
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
74
|
-
);
|
|
75
|
-
} catch (e) {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hasCrossOriginAttr(tag) {
|
|
81
|
-
return /(?:^|\s)crossorigin(?:=["']?[^"'\s>]*["']?)?(?:\s|>|$)/i.test(tag);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function getBundleKey(url, base = '') {
|
|
85
|
-
// Remove base prefix if exists
|
|
86
|
-
let cleanUrl = url.startsWith(base) ? url.slice(base.length) : url;
|
|
87
|
-
// Remove leading slash
|
|
88
|
-
cleanUrl = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl;
|
|
89
|
-
|
|
90
|
-
// Try different path combinations
|
|
91
|
-
const paths = [
|
|
92
|
-
cleanUrl,
|
|
93
|
-
`static/${cleanUrl}`,
|
|
94
|
-
cleanUrl.replace(/^static\//, '')
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
// Remove hash part if exists and try again
|
|
98
|
-
const withoutHash = cleanUrl.replace(/-[a-zA-Z0-9]+\.([^.]+)$/, '.$1');
|
|
99
|
-
if (withoutHash !== cleanUrl) {
|
|
100
|
-
paths.push(...[
|
|
101
|
-
withoutHash,
|
|
102
|
-
`static/${withoutHash}`,
|
|
103
|
-
withoutHash.replace(/^static\//, '')
|
|
104
|
-
]);
|
|
59
|
+
const response = await fetch(url, { timeout: 5000 });
|
|
60
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
61
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn(`[vite-plugin-sri4] Failed to fetch external resource: ${url}`, error);
|
|
64
|
+
return null
|
|
105
65
|
}
|
|
106
|
-
|
|
107
|
-
return [...new Set(paths)];
|
|
108
66
|
}
|
|
109
67
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
log(`Skip tag with existing integrity attribute: ${tag}`, options);
|
|
113
|
-
return tag;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
log(`Processing tag: ${tag}`, options);
|
|
117
|
-
log(`URL: ${url}`, options);
|
|
68
|
+
function createTransformer(options, config) {
|
|
69
|
+
const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
|
|
118
70
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
log(`Skip SRI for bypass domain: ${url}`, options);
|
|
123
|
-
return tag;
|
|
71
|
+
const getBundleKey = (htmlPath, url) => {
|
|
72
|
+
if (config.base === './' || config.base === '') {
|
|
73
|
+
return path.posix.resolve(htmlPath, url)
|
|
124
74
|
}
|
|
75
|
+
return url.replace(config.base, '')
|
|
76
|
+
};
|
|
125
77
|
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
return tag;
|
|
78
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
79
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
80
|
+
return null
|
|
130
81
|
}
|
|
131
82
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
83
|
+
let source;
|
|
84
|
+
if (url.startsWith('http')) {
|
|
85
|
+
const isSupported = await checkResourceSupport(url);
|
|
86
|
+
if (!isSupported) return null
|
|
87
|
+
source = await fetchResource(url);
|
|
88
|
+
if (!source) return null
|
|
89
|
+
} else {
|
|
90
|
+
const bundleItem = bundle[getBundleKey(htmlPath, url)];
|
|
91
|
+
if (!bundleItem) {
|
|
92
|
+
if (ignoreMissingAsset) return null
|
|
93
|
+
throw new Error(`Asset ${url} not found in bundle`)
|
|
143
94
|
}
|
|
144
|
-
|
|
145
|
-
log(`Failed to process external resource ${url}: ${error}`, options);
|
|
95
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
146
96
|
}
|
|
147
|
-
return tag;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Handle local resources
|
|
151
|
-
const possibleKeys = getBundleKey(url, base);
|
|
152
|
-
let bundleItem = null;
|
|
153
|
-
let source;
|
|
154
|
-
|
|
155
|
-
log(`Looking for bundle keys:`, options);
|
|
156
|
-
possibleKeys.forEach(key => log(`- ${key}`, options));
|
|
157
97
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
bundleItem = bundle[key];
|
|
161
|
-
log(`Found bundle item for key: ${key}`, options);
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
98
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
99
|
+
};
|
|
165
100
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
101
|
+
const transformHTML = async (bundle, htmlPath, html) => {
|
|
102
|
+
const changes = [];
|
|
103
|
+
|
|
104
|
+
for (const { regex, endOffset } of Object.values(HTML_PATTERNS)) {
|
|
105
|
+
const matches = [...html.matchAll(regex)];
|
|
106
|
+
for (const match of matches) {
|
|
107
|
+
const [, url] = match;
|
|
108
|
+
const end = match.index + match[0].length;
|
|
109
|
+
|
|
110
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
111
|
+
if (integrity) {
|
|
112
|
+
changes.push({
|
|
113
|
+
integrity,
|
|
114
|
+
position: end - endOffset
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
174
118
|
}
|
|
175
|
-
return tag;
|
|
176
|
-
}
|
|
177
119
|
|
|
178
|
-
|
|
120
|
+
changes.sort((a, b) => b.position - a.position);
|
|
179
121
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
log(`Processing chunk content of length: ${source.length}`, options);
|
|
184
|
-
} else {
|
|
185
|
-
source = bundleItem.source;
|
|
186
|
-
log(`Processing asset content of length: ${source.length}`, options);
|
|
122
|
+
for (const { integrity, position } of changes) {
|
|
123
|
+
const insertText = ` integrity="${integrity}"`;
|
|
124
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
187
125
|
}
|
|
188
126
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
log(`Computing SRI for local resource ${url}: ${integrity}`, options);
|
|
192
|
-
const hasCrossOrigin = hasCrossOriginAttr(tag);
|
|
193
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
194
|
-
const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
|
|
195
|
-
log(`New tag: ${newTag}`, options);
|
|
196
|
-
return newTag;
|
|
197
|
-
}
|
|
198
|
-
} catch (error) {
|
|
199
|
-
log(`Error processing bundle item: ${error}`, options);
|
|
200
|
-
if (!options.ignoreMissingAsset) {
|
|
201
|
-
console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
127
|
+
return html
|
|
128
|
+
};
|
|
204
129
|
|
|
205
|
-
return
|
|
130
|
+
return { transformHTML, calculateIntegrity }
|
|
206
131
|
}
|
|
207
132
|
|
|
208
|
-
function sri(
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
133
|
+
function sri(options = {}) {
|
|
134
|
+
const {
|
|
135
|
+
ignoreMissingAsset = false,
|
|
136
|
+
bypassDomains = [],
|
|
137
|
+
hashAlgorithm = 'sha384'
|
|
138
|
+
} = options;
|
|
212
139
|
|
|
213
140
|
return {
|
|
214
141
|
name: 'vite-plugin-sri4',
|
|
215
|
-
apply: 'build',
|
|
216
142
|
enforce: 'post',
|
|
217
|
-
|
|
143
|
+
apply: 'build',
|
|
218
144
|
configResolved(config) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
145
|
+
const transformer = createTransformer({
|
|
146
|
+
ignoreMissingAsset,
|
|
147
|
+
bypassDomains,
|
|
148
|
+
hashAlgorithm
|
|
149
|
+
}, config);
|
|
150
|
+
|
|
151
|
+
const generateBundle = async function(_, bundle) {
|
|
152
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
153
|
+
([, chunk]) =>
|
|
154
|
+
chunk.type === 'asset' &&
|
|
155
|
+
/\.html?$/.test(chunk.fileName)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await Promise.all(
|
|
159
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
160
|
+
chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
166
|
+
if (!plugin) {
|
|
167
|
+
throw new Error('vite-plugin-sri4 requires Vite 2.0.0 or higher')
|
|
231
168
|
}
|
|
232
169
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const newTag = await processTag(tag, url, options, bundle, base);
|
|
246
|
-
html = html.replace(tag, newTag);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Process link tags with and without quotes
|
|
250
|
-
const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
|
|
251
|
-
while ((match = linkTagRegex.exec(html)) !== null) {
|
|
252
|
-
const [tag, quotedUrl, unquotedUrl] = match;
|
|
253
|
-
const url = quotedUrl || unquotedUrl;
|
|
254
|
-
if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
|
|
255
|
-
log(`Processing link: ${url}`, options);
|
|
256
|
-
const newTag = await processTag(tag, url, options, bundle, base);
|
|
257
|
-
html = html.replace(tag, newTag);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return html;
|
|
262
|
-
} catch (error) {
|
|
263
|
-
console.error(`${LOG_PREFIX} Failed to transform HTML: ${error}`);
|
|
264
|
-
return html;
|
|
170
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
171
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
172
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
173
|
+
await originalHandler.apply(this, args);
|
|
174
|
+
await generateBundle.apply(this, args);
|
|
175
|
+
};
|
|
176
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
177
|
+
const originalHandler = plugin.generateBundle;
|
|
178
|
+
plugin.generateBundle = async function(...args) {
|
|
179
|
+
await originalHandler.apply(this, args);
|
|
180
|
+
await generateBundle.apply(this, args);
|
|
181
|
+
};
|
|
265
182
|
}
|
|
266
183
|
}
|
|
267
|
-
}
|
|
184
|
+
}
|
|
268
185
|
}
|
|
269
186
|
|
|
270
|
-
|
|
187
|
+
exports.sri = sri;
|
package/dist/index.js
CHANGED
|
@@ -1,268 +1,185 @@
|
|
|
1
|
-
import { createHash } from '
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import fetch from 'cross-fetch';
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
6
|
+
const HTML_PATTERNS = {
|
|
7
|
+
script: {
|
|
8
|
+
regex: /<script[^<>]*['"]*src['"]*=['"]*([^ '"]+)['"]*[^<>]*><\/script>/g,
|
|
9
|
+
endOffset: 10
|
|
10
|
+
},
|
|
11
|
+
stylesheet: {
|
|
12
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*stylesheet['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
13
|
+
endOffset: 1
|
|
14
|
+
},
|
|
15
|
+
modulepreload: {
|
|
16
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*modulepreload['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
17
|
+
endOffset: 1
|
|
18
|
+
}
|
|
12
19
|
};
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
if (options.debug) {
|
|
16
|
-
console.log(`${LOG_PREFIX} ${message}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
21
|
+
const urlSupportCache = new Map();
|
|
19
22
|
|
|
20
|
-
function
|
|
23
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
24
|
+
if (!url.startsWith('http')) return false
|
|
21
25
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error('Invalid content type');
|
|
29
|
-
}
|
|
30
|
-
return `${algorithm}-${hash.digest('base64')}`;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
33
|
-
return null;
|
|
26
|
+
const urlObj = new URL(url);
|
|
27
|
+
return bypassDomains.some(domain =>
|
|
28
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
29
|
+
)
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
async function
|
|
35
|
+
async function checkResourceSupport(url) {
|
|
36
|
+
if (urlSupportCache.has(url)) {
|
|
37
|
+
return urlSupportCache.get(url)
|
|
38
|
+
}
|
|
39
|
+
|
|
38
40
|
try {
|
|
39
41
|
const response = await fetch(url, {
|
|
40
|
-
method: 'HEAD'
|
|
42
|
+
method: 'HEAD',
|
|
43
|
+
timeout: 5000
|
|
41
44
|
});
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return false;
|
|
45
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
46
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
47
|
+
urlSupportCache.set(url, isSupported);
|
|
48
|
+
return isSupported
|
|
49
|
+
} catch {
|
|
50
|
+
urlSupportCache.set(url, false);
|
|
51
|
+
return false
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function
|
|
54
|
-
if (!bypassDomains.length) return false;
|
|
55
|
+
async function fetchResource(url) {
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else if (hostname.startsWith('//')) {
|
|
63
|
-
hostname = hostname.slice(2);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
hostname = hostname.split('/')[0];
|
|
67
|
-
|
|
68
|
-
hostname = hostname.split(':')[0];
|
|
69
|
-
|
|
70
|
-
return bypassDomains.some(domain =>
|
|
71
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
72
|
-
);
|
|
73
|
-
} catch (e) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function hasCrossOriginAttr(tag) {
|
|
79
|
-
return /(?:^|\s)crossorigin(?:=["']?[^"'\s>]*["']?)?(?:\s|>|$)/i.test(tag);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function getBundleKey(url, base = '') {
|
|
83
|
-
// Remove base prefix if exists
|
|
84
|
-
let cleanUrl = url.startsWith(base) ? url.slice(base.length) : url;
|
|
85
|
-
// Remove leading slash
|
|
86
|
-
cleanUrl = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl;
|
|
87
|
-
|
|
88
|
-
// Try different path combinations
|
|
89
|
-
const paths = [
|
|
90
|
-
cleanUrl,
|
|
91
|
-
`static/${cleanUrl}`,
|
|
92
|
-
cleanUrl.replace(/^static\//, '')
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
// Remove hash part if exists and try again
|
|
96
|
-
const withoutHash = cleanUrl.replace(/-[a-zA-Z0-9]+\.([^.]+)$/, '.$1');
|
|
97
|
-
if (withoutHash !== cleanUrl) {
|
|
98
|
-
paths.push(...[
|
|
99
|
-
withoutHash,
|
|
100
|
-
`static/${withoutHash}`,
|
|
101
|
-
withoutHash.replace(/^static\//, '')
|
|
102
|
-
]);
|
|
57
|
+
const response = await fetch(url, { timeout: 5000 });
|
|
58
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
59
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(`[vite-plugin-sri4] Failed to fetch external resource: ${url}`, error);
|
|
62
|
+
return null
|
|
103
63
|
}
|
|
104
|
-
|
|
105
|
-
return [...new Set(paths)];
|
|
106
64
|
}
|
|
107
65
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
log(`Skip tag with existing integrity attribute: ${tag}`, options);
|
|
111
|
-
return tag;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
log(`Processing tag: ${tag}`, options);
|
|
115
|
-
log(`URL: ${url}`, options);
|
|
66
|
+
function createTransformer(options, config) {
|
|
67
|
+
const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
|
|
116
68
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
log(`Skip SRI for bypass domain: ${url}`, options);
|
|
121
|
-
return tag;
|
|
69
|
+
const getBundleKey = (htmlPath, url) => {
|
|
70
|
+
if (config.base === './' || config.base === '') {
|
|
71
|
+
return path.posix.resolve(htmlPath, url)
|
|
122
72
|
}
|
|
73
|
+
return url.replace(config.base, '')
|
|
74
|
+
};
|
|
123
75
|
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
return tag;
|
|
76
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
77
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
78
|
+
return null
|
|
128
79
|
}
|
|
129
80
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
81
|
+
let source;
|
|
82
|
+
if (url.startsWith('http')) {
|
|
83
|
+
const isSupported = await checkResourceSupport(url);
|
|
84
|
+
if (!isSupported) return null
|
|
85
|
+
source = await fetchResource(url);
|
|
86
|
+
if (!source) return null
|
|
87
|
+
} else {
|
|
88
|
+
const bundleItem = bundle[getBundleKey(htmlPath, url)];
|
|
89
|
+
if (!bundleItem) {
|
|
90
|
+
if (ignoreMissingAsset) return null
|
|
91
|
+
throw new Error(`Asset ${url} not found in bundle`)
|
|
141
92
|
}
|
|
142
|
-
|
|
143
|
-
log(`Failed to process external resource ${url}: ${error}`, options);
|
|
93
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
144
94
|
}
|
|
145
|
-
return tag;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Handle local resources
|
|
149
|
-
const possibleKeys = getBundleKey(url, base);
|
|
150
|
-
let bundleItem = null;
|
|
151
|
-
let source;
|
|
152
|
-
|
|
153
|
-
log(`Looking for bundle keys:`, options);
|
|
154
|
-
possibleKeys.forEach(key => log(`- ${key}`, options));
|
|
155
95
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
bundleItem = bundle[key];
|
|
159
|
-
log(`Found bundle item for key: ${key}`, options);
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
96
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
97
|
+
};
|
|
163
98
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
99
|
+
const transformHTML = async (bundle, htmlPath, html) => {
|
|
100
|
+
const changes = [];
|
|
101
|
+
|
|
102
|
+
for (const { regex, endOffset } of Object.values(HTML_PATTERNS)) {
|
|
103
|
+
const matches = [...html.matchAll(regex)];
|
|
104
|
+
for (const match of matches) {
|
|
105
|
+
const [, url] = match;
|
|
106
|
+
const end = match.index + match[0].length;
|
|
107
|
+
|
|
108
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
109
|
+
if (integrity) {
|
|
110
|
+
changes.push({
|
|
111
|
+
integrity,
|
|
112
|
+
position: end - endOffset
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
172
116
|
}
|
|
173
|
-
return tag;
|
|
174
|
-
}
|
|
175
117
|
|
|
176
|
-
|
|
118
|
+
changes.sort((a, b) => b.position - a.position);
|
|
177
119
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
log(`Processing chunk content of length: ${source.length}`, options);
|
|
182
|
-
} else {
|
|
183
|
-
source = bundleItem.source;
|
|
184
|
-
log(`Processing asset content of length: ${source.length}`, options);
|
|
120
|
+
for (const { integrity, position } of changes) {
|
|
121
|
+
const insertText = ` integrity="${integrity}"`;
|
|
122
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
185
123
|
}
|
|
186
124
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
log(`Computing SRI for local resource ${url}: ${integrity}`, options);
|
|
190
|
-
const hasCrossOrigin = hasCrossOriginAttr(tag);
|
|
191
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
192
|
-
const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
|
|
193
|
-
log(`New tag: ${newTag}`, options);
|
|
194
|
-
return newTag;
|
|
195
|
-
}
|
|
196
|
-
} catch (error) {
|
|
197
|
-
log(`Error processing bundle item: ${error}`, options);
|
|
198
|
-
if (!options.ignoreMissingAsset) {
|
|
199
|
-
console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
125
|
+
return html
|
|
126
|
+
};
|
|
202
127
|
|
|
203
|
-
return
|
|
128
|
+
return { transformHTML, calculateIntegrity }
|
|
204
129
|
}
|
|
205
130
|
|
|
206
|
-
function sri(
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
131
|
+
function sri(options = {}) {
|
|
132
|
+
const {
|
|
133
|
+
ignoreMissingAsset = false,
|
|
134
|
+
bypassDomains = [],
|
|
135
|
+
hashAlgorithm = 'sha384'
|
|
136
|
+
} = options;
|
|
210
137
|
|
|
211
138
|
return {
|
|
212
139
|
name: 'vite-plugin-sri4',
|
|
213
|
-
apply: 'build',
|
|
214
140
|
enforce: 'post',
|
|
215
|
-
|
|
141
|
+
apply: 'build',
|
|
216
142
|
configResolved(config) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
143
|
+
const transformer = createTransformer({
|
|
144
|
+
ignoreMissingAsset,
|
|
145
|
+
bypassDomains,
|
|
146
|
+
hashAlgorithm
|
|
147
|
+
}, config);
|
|
148
|
+
|
|
149
|
+
const generateBundle = async function(_, bundle) {
|
|
150
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
151
|
+
([, chunk]) =>
|
|
152
|
+
chunk.type === 'asset' &&
|
|
153
|
+
/\.html?$/.test(chunk.fileName)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await Promise.all(
|
|
157
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
158
|
+
chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
164
|
+
if (!plugin) {
|
|
165
|
+
throw new Error('vite-plugin-sri4 requires Vite 2.0.0 or higher')
|
|
229
166
|
}
|
|
230
167
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const newTag = await processTag(tag, url, options, bundle, base);
|
|
244
|
-
html = html.replace(tag, newTag);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Process link tags with and without quotes
|
|
248
|
-
const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
|
|
249
|
-
while ((match = linkTagRegex.exec(html)) !== null) {
|
|
250
|
-
const [tag, quotedUrl, unquotedUrl] = match;
|
|
251
|
-
const url = quotedUrl || unquotedUrl;
|
|
252
|
-
if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
|
|
253
|
-
log(`Processing link: ${url}`, options);
|
|
254
|
-
const newTag = await processTag(tag, url, options, bundle, base);
|
|
255
|
-
html = html.replace(tag, newTag);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return html;
|
|
260
|
-
} catch (error) {
|
|
261
|
-
console.error(`${LOG_PREFIX} Failed to transform HTML: ${error}`);
|
|
262
|
-
return html;
|
|
168
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
169
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
170
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
171
|
+
await originalHandler.apply(this, args);
|
|
172
|
+
await generateBundle.apply(this, args);
|
|
173
|
+
};
|
|
174
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
175
|
+
const originalHandler = plugin.generateBundle;
|
|
176
|
+
plugin.generateBundle = async function(...args) {
|
|
177
|
+
await originalHandler.apply(this, args);
|
|
178
|
+
await generateBundle.apply(this, args);
|
|
179
|
+
};
|
|
263
180
|
}
|
|
264
181
|
}
|
|
265
|
-
}
|
|
182
|
+
}
|
|
266
183
|
}
|
|
267
184
|
|
|
268
|
-
export { sri
|
|
185
|
+
export { sri };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-sri4",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"prepublishOnly": "npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"cheerio": "^1.0.0",
|
|
26
27
|
"cross-fetch": "^4.1.0"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -32,9 +33,11 @@
|
|
|
32
33
|
"@rollup/plugin-commonjs": "^25.0.7",
|
|
33
34
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
34
35
|
"@vitest/coverage-v8": "^1.2.2",
|
|
36
|
+
"cross-fetch": "^4.0.0",
|
|
35
37
|
"rollup": "^4.9.6",
|
|
36
38
|
"vite": "^5.0.12",
|
|
37
|
-
"vitest": "^1.2.2"
|
|
39
|
+
"vitest": "^1.2.2",
|
|
40
|
+
"memfs": "^4.6.0"
|
|
38
41
|
},
|
|
39
42
|
"keywords": [
|
|
40
43
|
"vite",
|