Skip to content

Commit fbdf96b

Browse files
harlan-zwclaude
andauthored
feat(bundling): add automatic SRI integrity hash generation (#575)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4c79486 commit fbdf96b

File tree

5 files changed

+381
-24
lines changed

5 files changed

+381
-24
lines changed

docs/content/docs/1.guides/2.bundling.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export default defineNuxtConfig({
254254
assets: {
255255
prefix: '/_custom-script-path/',
256256
cacheMaxAge: 86400000, // 1 day in milliseconds
257+
integrity: true, // Enable SRI hash generation
257258
}
258259
}
259260
})
@@ -263,6 +264,7 @@ export default defineNuxtConfig({
263264

264265
- **`prefix`** - Custom path where bundled scripts are served (default: `/_scripts/`)
265266
- **`cacheMaxAge`** - Cache duration for bundled scripts in milliseconds (default: 7 days)
267+
- **`integrity`** - Enable automatic SRI (Subresource Integrity) hash generation (default: `false`)
266268

267269
#### Cache Behavior
268270

@@ -272,3 +274,57 @@ The bundling system uses two different cache strategies:
272274
- **Runtime cache**: Bundled scripts are served with 1-year cache headers since they are content-addressed by hash.
273275

274276
This dual approach ensures both build performance and reliable browser caching.
277+
278+
### Subresource Integrity (SRI)
279+
280+
Subresource Integrity (SRI) is a security feature that ensures scripts haven't been tampered with. When enabled, a cryptographic hash is calculated for each bundled script and added as an `integrity` attribute.
281+
282+
#### Enabling SRI
283+
284+
```ts [nuxt.config.ts]
285+
export default defineNuxtConfig({
286+
scripts: {
287+
assets: {
288+
integrity: true, // Uses sha384 by default
289+
}
290+
}
291+
})
292+
```
293+
294+
#### Hash Algorithms
295+
296+
You can specify the hash algorithm:
297+
298+
```ts [nuxt.config.ts]
299+
export default defineNuxtConfig({
300+
scripts: {
301+
assets: {
302+
integrity: 'sha384', // Default, recommended balance of security/size
303+
// integrity: 'sha256', // Smaller hash
304+
// integrity: 'sha512', // Strongest security
305+
}
306+
}
307+
})
308+
```
309+
310+
#### How It Works
311+
312+
When `integrity` is enabled:
313+
314+
1. During build, each bundled script's content is hashed
315+
2. The hash is stored in the build cache for reuse
316+
3. The `integrity` attribute is injected into the script tag
317+
4. The `crossorigin="anonymous"` attribute is automatically added (required by browsers for SRI)
318+
319+
```html
320+
<!-- Output with integrity enabled -->
321+
<script src="/_scripts/abc123.js"
322+
integrity="sha384-oqVuAfXRKap..."
323+
crossorigin="anonymous"></script>
324+
```
325+
326+
#### Security Benefits
327+
328+
- **Tamper detection**: Browser refuses to execute scripts if the hash doesn't match
329+
- **CDN compromise protection**: Even if your CDN is compromised, modified scripts won't execute
330+
- **Build-time verification**: Hash is calculated from the actual downloaded content

docs/content/docs/3.api/5.nuxt-config.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,12 @@ Fallback to the remote src URL when `bundle` fails when enabled. By default, the
6363
- Default: `{ retry: 3, retryDelay: 2000, timeout: 15_000 }`
6464

6565
Options to pass to the fetch function when downloading scripts.
66+
67+
## `assets.integrity`
68+
69+
- Type: `boolean | 'sha256' | 'sha384' | 'sha512'`
70+
- Default: `false`
71+
72+
Enable automatic Subresource Integrity (SRI) hash generation for bundled scripts. When enabled, calculates a cryptographic hash of each bundled script and injects the `integrity` attribute along with `crossorigin="anonymous"`.
73+
74+
See the [Bundling - Subresource Integrity](/docs/guides/bundling#subresource-integrity-sri) documentation for more details.

src/module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export interface ModuleOptions {
6969
* @default 604800000 (7 days)
7070
*/
7171
cacheMaxAge?: number
72+
/**
73+
* Enable automatic integrity hash generation for bundled scripts.
74+
* When enabled, calculates SRI (Subresource Integrity) hash and injects
75+
* integrity attribute along with crossorigin="anonymous".
76+
*
77+
* @default false
78+
*/
79+
integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
7280
}
7381
/**
7482
* Whether the module is enabled.
@@ -231,6 +239,7 @@ export default defineNuxtModule<ModuleOptions>({
231239
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
232240
fetchOptions: config.assets?.fetchOptions,
233241
cacheMaxAge: config.assets?.cacheMaxAge,
242+
integrity: config.assets?.integrity,
234243
renderedScript,
235244
}))
236245

src/plugins/transform.ts

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto'
12
import fsp from 'node:fs/promises'
23
import { createUnplugin } from 'unplugin'
34
import MagicString from 'magic-string'
@@ -20,6 +21,13 @@ import type { RegistryScript } from '#nuxt-scripts/types'
2021

2122
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000
2223

24+
export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512'
25+
26+
function calculateIntegrity(content: Buffer, algorithm: IntegrityAlgorithm = 'sha384'): string {
27+
const hash = createHash(algorithm).update(content).digest('base64')
28+
return `${algorithm}-${hash}`
29+
}
30+
2331
export async function isCacheExpired(storage: any, filename: string, cacheMaxAge: number = SEVEN_DAYS_IN_MS): Promise<boolean> {
2432
const metaKey = `bundle-meta:${filename}`
2533
const meta = await storage.getItem(metaKey)
@@ -29,6 +37,18 @@ export async function isCacheExpired(storage: any, filename: string, cacheMaxAge
2937
return Date.now() - meta.timestamp > cacheMaxAge
3038
}
3139

40+
export interface RenderedScriptMeta {
41+
content: Buffer
42+
/**
43+
* in kb
44+
*/
45+
size: number
46+
encoding?: string
47+
src: string
48+
filename?: string
49+
integrity?: string
50+
}
51+
3252
export interface AssetBundlerTransformerOptions {
3353
moduleDetected?: (module: string) => void
3454
defaultBundle?: boolean | 'force'
@@ -42,16 +62,13 @@ export interface AssetBundlerTransformerOptions {
4262
fallbackOnSrcOnBundleFail?: boolean
4363
fetchOptions?: FetchOptions
4464
cacheMaxAge?: number
45-
renderedScript?: Map<string, {
46-
content: Buffer
47-
/**
48-
* in kb
49-
*/
50-
size: number
51-
encoding?: string
52-
src: string
53-
filename?: string
54-
} | Error>
65+
/**
66+
* Enable automatic integrity hash generation for bundled scripts.
67+
* When enabled, calculates SRI hash and injects integrity attribute.
68+
* @default false
69+
*/
70+
integrity?: boolean | IntegrityAlgorithm
71+
renderedScript?: Map<string, RenderedScriptMeta | Error>
5572
}
5673

5774
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } {
@@ -74,8 +91,9 @@ async function downloadScript(opts: {
7491
url: string
7592
filename?: string
7693
forceDownload?: boolean
94+
integrity?: boolean | IntegrityAlgorithm
7795
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
78-
const { src, url, filename, forceDownload } = opts
96+
const { src, url, filename, forceDownload, integrity } = opts
7997
if (src === url || !filename) {
8098
return
8199
}
@@ -88,15 +106,16 @@ async function downloadScript(opts: {
88106
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
89107

90108
if (shouldUseCache) {
91-
const res = await storage.getItemRaw<Buffer>(cacheKey)
109+
const cachedContent = await storage.getItemRaw<Buffer>(cacheKey)
110+
const meta = await storage.getItem(`bundle-meta:${filename}`) as { integrity?: string } | null
92111
renderedScript.set(url, {
93-
content: res!,
94-
size: res!.length / 1024,
112+
content: cachedContent!,
113+
size: cachedContent!.length / 1024,
95114
encoding: 'utf-8',
96115
src,
97116
filename,
117+
integrity: meta?.integrity,
98118
})
99-
100119
return
101120
}
102121
let encoding
@@ -111,21 +130,28 @@ async function downloadScript(opts: {
111130
return Buffer.from(r._data || await r.arrayBuffer())
112131
})
113132

133+
// Calculate integrity hash if enabled
134+
const integrityHash = integrity && res
135+
? calculateIntegrity(res, integrity === true ? 'sha384' : integrity)
136+
: undefined
137+
114138
await storage.setItemRaw(`bundle:${filename}`, res)
115139
// Save metadata with timestamp for cache expiration
116140
await storage.setItem(`bundle-meta:${filename}`, {
117141
timestamp: Date.now(),
118142
src,
119143
filename,
144+
integrity: integrityHash,
120145
})
121146
size = size || res!.length / 1024
122-
logger.info(`Downloading script ${colors.gray(`${src}${filename} (${size.toFixed(2)} kB ${encoding})`)}`)
147+
logger.info(`Downloading script ${colors.gray(`${src}${filename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`)
123148
renderedScript.set(url, {
124149
content: res!,
125150
size,
126151
encoding,
127152
src,
128153
filename,
154+
integrity: integrityHash,
129155
})
130156
}
131157
}
@@ -335,7 +361,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
335361
const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL)
336362
let url = _url
337363
try {
338-
await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge)
364+
await downloadScript({ src, url, filename, forceDownload, integrity: options.integrity }, renderedScript, options.fetchOptions, options.cacheMaxAge)
339365
}
340366
catch (e: any) {
341367
if (options.fallbackOnSrcOnBundleFail) {
@@ -359,11 +385,29 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
359385
else
360386
logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`)
361387
}
388+
389+
// Get the integrity hash from rendered script
390+
const scriptMeta = renderedScript.get(url)
391+
const integrityHash = scriptMeta instanceof Error ? undefined : scriptMeta?.integrity
392+
362393
if (scriptSrcNode) {
363-
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
394+
// For useScript('src') pattern, we need to convert to object form to add integrity
395+
if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'Literal') {
396+
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `{ src: '${url}', integrity: '${integrityHash}', crossorigin: 'anonymous' }`)
397+
}
398+
else if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'ObjectExpression') {
399+
// For useScript({ src: '...' }) pattern, update src and add integrity
400+
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
401+
const objArg = node.arguments[0] as ObjectExpression & { end: number }
402+
s.appendLeft(objArg.end - 1, `, integrity: '${integrityHash}', crossorigin: 'anonymous'`)
403+
}
404+
else {
405+
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
406+
}
364407
}
365408
else {
366-
// Handle case where we need to add scriptInput
409+
// Handle case where we need to add scriptInput (registry scripts)
410+
const integrityProps = integrityHash ? `, integrity: '${integrityHash}', crossorigin: 'anonymous'` : ''
367411
if (node.arguments[0]) {
368412
// There's at least one argument
369413
const optionsNode = node.arguments[0] as ObjectExpression
@@ -379,21 +423,25 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
379423
const srcProperty = scriptInput.properties.find(
380424
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
381425
)
382-
if (srcProperty)
426+
if (srcProperty) {
383427
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`)
384-
else
385-
s.appendRight(scriptInput.end, `, src: '${url}'`)
428+
if (integrityHash)
429+
s.appendLeft(scriptInput.end - 1, integrityProps)
430+
}
431+
else {
432+
s.appendRight(scriptInput.end - 1, `, src: '${url}'${integrityProps}`)
433+
}
386434
}
387435
}
388436
else {
389437
// @ts-expect-error untyped
390-
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `)
438+
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}'${integrityProps} }, `)
391439
}
392440
}
393441
else {
394442
// No arguments at all, need to create the first argument
395443
// @ts-expect-error untyped
396-
s.appendRight(node.callee.end, `({ scriptInput: { src: '${url}' } })`)
444+
s.appendRight(node.callee.end, `({ scriptInput: { src: '${url}'${integrityProps} } })`)
397445
}
398446
}
399447
}

0 commit comments

Comments
 (0)