diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/2.bundling.md
index c0e6cd28..5ff006fa 100644
--- a/docs/content/docs/1.guides/2.bundling.md
+++ b/docs/content/docs/1.guides/2.bundling.md
@@ -254,6 +254,7 @@ export default defineNuxtConfig({
assets: {
prefix: '/_custom-script-path/',
cacheMaxAge: 86400000, // 1 day in milliseconds
+ integrity: true, // Enable SRI hash generation
}
}
})
@@ -263,6 +264,7 @@ export default defineNuxtConfig({
- **`prefix`** - Custom path where bundled scripts are served (default: `/_scripts/`)
- **`cacheMaxAge`** - Cache duration for bundled scripts in milliseconds (default: 7 days)
+- **`integrity`** - Enable automatic SRI (Subresource Integrity) hash generation (default: `false`)
#### Cache Behavior
@@ -272,3 +274,57 @@ The bundling system uses two different cache strategies:
- **Runtime cache**: Bundled scripts are served with 1-year cache headers since they are content-addressed by hash.
This dual approach ensures both build performance and reliable browser caching.
+
+### Subresource Integrity (SRI)
+
+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.
+
+#### Enabling SRI
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ scripts: {
+ assets: {
+ integrity: true, // Uses sha384 by default
+ }
+ }
+})
+```
+
+#### Hash Algorithms
+
+You can specify the hash algorithm:
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ scripts: {
+ assets: {
+ integrity: 'sha384', // Default, recommended balance of security/size
+ // integrity: 'sha256', // Smaller hash
+ // integrity: 'sha512', // Strongest security
+ }
+ }
+})
+```
+
+#### How It Works
+
+When `integrity` is enabled:
+
+1. During build, each bundled script's content is hashed
+2. The hash is stored in the build cache for reuse
+3. The `integrity` attribute is injected into the script tag
+4. The `crossorigin="anonymous"` attribute is automatically added (required by browsers for SRI)
+
+```html
+
+
+```
+
+#### Security Benefits
+
+- **Tamper detection**: Browser refuses to execute scripts if the hash doesn't match
+- **CDN compromise protection**: Even if your CDN is compromised, modified scripts won't execute
+- **Build-time verification**: Hash is calculated from the actual downloaded content
diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md
index 0ee5beca..7ebe7424 100644
--- a/docs/content/docs/3.api/5.nuxt-config.md
+++ b/docs/content/docs/3.api/5.nuxt-config.md
@@ -63,3 +63,12 @@ Fallback to the remote src URL when `bundle` fails when enabled. By default, the
- Default: `{ retry: 3, retryDelay: 2000, timeout: 15_000 }`
Options to pass to the fetch function when downloading scripts.
+
+## `assets.integrity`
+
+- Type: `boolean | 'sha256' | 'sha384' | 'sha512'`
+- Default: `false`
+
+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"`.
+
+See the [Bundling - Subresource Integrity](/docs/guides/bundling#subresource-integrity-sri) documentation for more details.
diff --git a/src/module.ts b/src/module.ts
index dd8acff1..73f5668f 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -69,6 +69,14 @@ export interface ModuleOptions {
* @default 604800000 (7 days)
*/
cacheMaxAge?: number
+ /**
+ * Enable automatic integrity hash generation for bundled scripts.
+ * When enabled, calculates SRI (Subresource Integrity) hash and injects
+ * integrity attribute along with crossorigin="anonymous".
+ *
+ * @default false
+ */
+ integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
}
/**
* Whether the module is enabled.
@@ -231,6 +239,7 @@ export default defineNuxtModule({
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
fetchOptions: config.assets?.fetchOptions,
cacheMaxAge: config.assets?.cacheMaxAge,
+ integrity: config.assets?.integrity,
renderedScript,
}))
diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts
index 2e4917e3..7cf5cb35 100644
--- a/src/plugins/transform.ts
+++ b/src/plugins/transform.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto'
import fsp from 'node:fs/promises'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
@@ -20,6 +21,13 @@ import type { RegistryScript } from '#nuxt-scripts/types'
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000
+export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512'
+
+function calculateIntegrity(content: Buffer, algorithm: IntegrityAlgorithm = 'sha384'): string {
+ const hash = createHash(algorithm).update(content).digest('base64')
+ return `${algorithm}-${hash}`
+}
+
export async function isCacheExpired(storage: any, filename: string, cacheMaxAge: number = SEVEN_DAYS_IN_MS): Promise {
const metaKey = `bundle-meta:${filename}`
const meta = await storage.getItem(metaKey)
@@ -29,6 +37,18 @@ export async function isCacheExpired(storage: any, filename: string, cacheMaxAge
return Date.now() - meta.timestamp > cacheMaxAge
}
+export interface RenderedScriptMeta {
+ content: Buffer
+ /**
+ * in kb
+ */
+ size: number
+ encoding?: string
+ src: string
+ filename?: string
+ integrity?: string
+}
+
export interface AssetBundlerTransformerOptions {
moduleDetected?: (module: string) => void
defaultBundle?: boolean | 'force'
@@ -42,16 +62,13 @@ export interface AssetBundlerTransformerOptions {
fallbackOnSrcOnBundleFail?: boolean
fetchOptions?: FetchOptions
cacheMaxAge?: number
- renderedScript?: Map
+ /**
+ * Enable automatic integrity hash generation for bundled scripts.
+ * When enabled, calculates SRI hash and injects integrity attribute.
+ * @default false
+ */
+ integrity?: boolean | IntegrityAlgorithm
+ renderedScript?: Map
}
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } {
@@ -74,8 +91,9 @@ async function downloadScript(opts: {
url: string
filename?: string
forceDownload?: boolean
+ integrity?: boolean | IntegrityAlgorithm
}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
- const { src, url, filename, forceDownload } = opts
+ const { src, url, filename, forceDownload, integrity } = opts
if (src === url || !filename) {
return
}
@@ -88,15 +106,16 @@ async function downloadScript(opts: {
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
if (shouldUseCache) {
- const res = await storage.getItemRaw(cacheKey)
+ const cachedContent = await storage.getItemRaw(cacheKey)
+ const meta = await storage.getItem(`bundle-meta:${filename}`) as { integrity?: string } | null
renderedScript.set(url, {
- content: res!,
- size: res!.length / 1024,
+ content: cachedContent!,
+ size: cachedContent!.length / 1024,
encoding: 'utf-8',
src,
filename,
+ integrity: meta?.integrity,
})
-
return
}
let encoding
@@ -111,21 +130,28 @@ async function downloadScript(opts: {
return Buffer.from(r._data || await r.arrayBuffer())
})
+ // Calculate integrity hash if enabled
+ const integrityHash = integrity && res
+ ? calculateIntegrity(res, integrity === true ? 'sha384' : integrity)
+ : undefined
+
await storage.setItemRaw(`bundle:${filename}`, res)
// Save metadata with timestamp for cache expiration
await storage.setItem(`bundle-meta:${filename}`, {
timestamp: Date.now(),
src,
filename,
+ integrity: integrityHash,
})
size = size || res!.length / 1024
- logger.info(`Downloading script ${colors.gray(`${src} → ${filename} (${size.toFixed(2)} kB ${encoding})`)}`)
+ logger.info(`Downloading script ${colors.gray(`${src} → ${filename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`)
renderedScript.set(url, {
content: res!,
size,
encoding,
src,
filename,
+ integrity: integrityHash,
})
}
}
@@ -335,7 +361,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL)
let url = _url
try {
- await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge)
+ await downloadScript({ src, url, filename, forceDownload, integrity: options.integrity }, renderedScript, options.fetchOptions, options.cacheMaxAge)
}
catch (e: any) {
if (options.fallbackOnSrcOnBundleFail) {
@@ -359,11 +385,29 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
else
logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`)
}
+
+ // Get the integrity hash from rendered script
+ const scriptMeta = renderedScript.get(url)
+ const integrityHash = scriptMeta instanceof Error ? undefined : scriptMeta?.integrity
+
if (scriptSrcNode) {
- s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
+ // For useScript('src') pattern, we need to convert to object form to add integrity
+ if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'Literal') {
+ s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `{ src: '${url}', integrity: '${integrityHash}', crossorigin: 'anonymous' }`)
+ }
+ else if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'ObjectExpression') {
+ // For useScript({ src: '...' }) pattern, update src and add integrity
+ s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
+ const objArg = node.arguments[0] as ObjectExpression & { end: number }
+ s.appendLeft(objArg.end - 1, `, integrity: '${integrityHash}', crossorigin: 'anonymous'`)
+ }
+ else {
+ s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
+ }
}
else {
- // Handle case where we need to add scriptInput
+ // Handle case where we need to add scriptInput (registry scripts)
+ const integrityProps = integrityHash ? `, integrity: '${integrityHash}', crossorigin: 'anonymous'` : ''
if (node.arguments[0]) {
// There's at least one argument
const optionsNode = node.arguments[0] as ObjectExpression
@@ -379,21 +423,25 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
const srcProperty = scriptInput.properties.find(
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
)
- if (srcProperty)
+ if (srcProperty) {
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`)
- else
- s.appendRight(scriptInput.end, `, src: '${url}'`)
+ if (integrityHash)
+ s.appendLeft(scriptInput.end - 1, integrityProps)
+ }
+ else {
+ s.appendRight(scriptInput.end - 1, `, src: '${url}'${integrityProps}`)
+ }
}
}
else {
// @ts-expect-error untyped
- s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `)
+ s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}'${integrityProps} }, `)
}
}
else {
// No arguments at all, need to create the first argument
// @ts-expect-error untyped
- s.appendRight(node.callee.end, `({ scriptInput: { src: '${url}' } })`)
+ s.appendRight(node.callee.end, `({ scriptInput: { src: '${url}'${integrityProps} } })`)
}
}
}
diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts
index 90867a33..8d317e00 100644
--- a/test/unit/transform.test.ts
+++ b/test/unit/transform.test.ts
@@ -982,6 +982,241 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/gtm.js.js' }, id: 'GTM-FUNCTION-ARG' })"`)
})
+ describe('integrity', () => {
+ beforeEach(() => {
+ mockBundleStorage.getItem.mockReset()
+ mockBundleStorage.setItem.mockReset()
+ mockBundleStorage.getItemRaw.mockReset()
+ mockBundleStorage.setItemRaw.mockReset()
+ mockBundleStorage.hasItem.mockReset()
+ vi.clearAllMocks()
+ })
+
+ it('injects integrity attribute for useScript(string) pattern', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: true,
+ renderedScript: new Map(),
+ },
+ )
+
+ // Should convert to object form with integrity and crossorigin
+ expect(code).toContain('integrity:')
+ expect(code).toContain('sha384-')
+ expect(code).toContain(`crossorigin: 'anonymous'`)
+ })
+
+ it('injects integrity attribute for useScript({ src }) pattern', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScript({ src: 'https://static.cloudflareinsights.com/beacon.min.js' }, {
+ bundle: true,
+ })`,
+ {
+ integrity: true,
+ renderedScript: new Map(),
+ },
+ )
+
+ expect(code).toContain('integrity:')
+ expect(code).toContain('sha384-')
+ expect(code).toContain(`crossorigin: 'anonymous'`)
+ })
+
+ it('injects integrity for registry scripts via scriptInput', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'gtag')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("analytics")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScriptGoogleAnalytics({ id: 'GA-123' }, { bundle: true })`,
+ {
+ integrity: true,
+ renderedScript: new Map(),
+ scripts: [
+ {
+ scriptBundling() {
+ return 'https://www.googletagmanager.com/gtag/js'
+ },
+ import: {
+ name: 'useScriptGoogleAnalytics',
+ from: '',
+ },
+ },
+ ],
+ },
+ )
+
+ expect(code).toContain('scriptInput:')
+ expect(code).toContain('integrity:')
+ expect(code).toContain('sha384-')
+ expect(code).toContain(`crossorigin: 'anonymous'`)
+ })
+
+ it('supports sha256 algorithm', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: 'sha256',
+ renderedScript: new Map(),
+ },
+ )
+
+ expect(code).toContain('sha256-')
+ })
+
+ it('supports sha512 algorithm', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: 'sha512',
+ renderedScript: new Map(),
+ },
+ )
+
+ expect(code).toContain('sha512-')
+ })
+
+ it('does not inject integrity when disabled', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ const code = await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: false,
+ renderedScript: new Map(),
+ },
+ )
+
+ expect(code).not.toContain('integrity:')
+ expect(code).not.toContain('crossorigin:')
+ })
+
+ it('loads cached integrity hash', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+
+ const cachedContent = Buffer.from('cached script content')
+ const cachedIntegrity = 'sha384-cachedHashValue'
+
+ mockBundleStorage.hasItem.mockResolvedValue(true)
+ mockBundleStorage.getItem.mockResolvedValue({
+ timestamp: Date.now(),
+ integrity: cachedIntegrity,
+ })
+ mockBundleStorage.getItemRaw.mockResolvedValue(cachedContent)
+
+ const code = await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: true,
+ renderedScript: new Map(),
+ },
+ )
+
+ expect(code).toContain(cachedIntegrity)
+ })
+
+ it('stores integrity hash in metadata', async () => {
+ vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+ mockBundleStorage.hasItem.mockResolvedValue(false)
+
+ const scriptContent = Buffer.from('console.log("test")')
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(scriptContent),
+ headers: { get: () => null },
+ _data: scriptContent,
+ } as any)
+
+ await transform(
+ `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+ bundle: true,
+ })`,
+ {
+ integrity: true,
+ renderedScript: new Map(),
+ },
+ )
+
+ const metadataCall = mockBundleStorage.setItem.mock.calls.find(call =>
+ call[0].startsWith('bundle-meta:'),
+ )
+ expect(metadataCall).toBeDefined()
+ expect(metadataCall[1].integrity).toBeDefined()
+ expect(metadataCall[1].integrity).toMatch(/^sha384-/)
+ })
+ })
+
describe.todo('fallbackOnSrcOnBundleFail', () => {
beforeEach(() => {
vi.mocked($fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error')))