Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/content/docs/1.guides/2.bundling.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export default defineNuxtConfig({
assets: {
prefix: '/_custom-script-path/',
cacheMaxAge: 86400000, // 1 day in milliseconds
integrity: true, // Enable SRI hash generation
}
}
})
Expand All @@ -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

Expand All @@ -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
<!-- Output with integrity enabled -->
<script src="/_scripts/abc123.js"
integrity="sha384-oqVuAfXRKap..."
crossorigin="anonymous"></script>
```

#### 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
9 changes: 9 additions & 0 deletions docs/content/docs/3.api/5.nuxt-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -231,6 +239,7 @@ export default defineNuxtModule<ModuleOptions>({
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
fetchOptions: config.assets?.fetchOptions,
cacheMaxAge: config.assets?.cacheMaxAge,
integrity: config.assets?.integrity,
renderedScript,
}))

Expand Down
96 changes: 72 additions & 24 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import fsp from 'node:fs/promises'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
Expand All @@ -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<boolean> {
const metaKey = `bundle-meta:${filename}`
const meta = await storage.getItem(metaKey)
Expand All @@ -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'
Expand All @@ -42,16 +62,13 @@ export interface AssetBundlerTransformerOptions {
fallbackOnSrcOnBundleFail?: boolean
fetchOptions?: FetchOptions
cacheMaxAge?: number
renderedScript?: Map<string, {
content: Buffer
/**
* in kb
*/
size: number
encoding?: string
src: string
filename?: string
} | Error>
/**
* Enable automatic integrity hash generation for bundled scripts.
* When enabled, calculates SRI hash and injects integrity attribute.
* @default false
*/
integrity?: boolean | IntegrityAlgorithm
renderedScript?: Map<string, RenderedScriptMeta | Error>
}

function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } {
Expand All @@ -74,8 +91,9 @@ async function downloadScript(opts: {
url: string
filename?: string
forceDownload?: boolean
integrity?: boolean | IntegrityAlgorithm
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
const { src, url, filename, forceDownload } = opts
const { src, url, filename, forceDownload, integrity } = opts
if (src === url || !filename) {
return
}
Expand All @@ -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<Buffer>(cacheKey)
const cachedContent = await storage.getItemRaw<Buffer>(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
Expand All @@ -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,
})
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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} } })`)
}
}
}
Expand Down
Loading
Loading