From df7931be9ba48c3b0e6db0e978a0372366a548a1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 02:00:34 +1100 Subject: [PATCH] feat(bundling): add automatic SRI integrity hash generation When enabled, calculates SHA-384 (or configurable sha256/sha512) hash for bundled scripts and injects `integrity` and `crossorigin="anonymous"` attributes. Configuration: ```ts scripts: { assets: { integrity: true // or 'sha256' | 'sha384' | 'sha512' } } ``` Co-Authored-By: Claude Opus 4.5 --- docs/content/docs/1.guides/2.bundling.md | 56 ++++++ docs/content/docs/3.api/5.nuxt-config.md | 9 + src/module.ts | 9 + src/plugins/transform.ts | 96 ++++++--- test/unit/transform.test.ts | 235 +++++++++++++++++++++++ 5 files changed, 381 insertions(+), 24 deletions(-) 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')))