Skip to content

Commit

Permalink
Merge pull request #285 from vejja/sri
Browse files Browse the repository at this point in the history
  • Loading branch information
Baroshem authored Nov 9, 2023
2 parents 5e843b7 + 4194598 commit 64a2aa4
Show file tree
Hide file tree
Showing 19 changed files with 440 additions and 64 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- '**-rc.**'
- 'renovate/**'
pull_request:
workflow_dispatch:

jobs:
ci:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface ModuleOptions {
nonce: boolean;
removeLoggers?: RemoveOptions | false;
ssg?: Ssg;
sri: boolean;
}
```

Expand Down Expand Up @@ -116,7 +117,8 @@ security: {
ssg: {
hashScripts: true,
hashStyles: false
}
},
sri: true
}
```

Expand Down
69 changes: 69 additions & 0 deletions docs/content/1.documentation/4.utils/3.subresource-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Subresource Integrity

:badge[Enabled]{type="success"} Ensure that your application bundle has not been manipulated.

---

:ellipsis{right=0px width=75% blur=150px}

Subresource Integrity (SRI) is a security feature that enables the browser to verify that the static assets that your application is loading have not been altered.

Nuxt Security automatically computes the integrity hash of each static asset (scripts, stylesheets, etc.) that are bundled in your Nuxt Application, and then inserts this value in the resulting HTML file.


::alert{type="info"}
ℹ Read more about Subresource Integrity [here](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity).
::

## Options

This feature is enabled globally by default. You can customize it like following:

```js{}[nuxt.config.ts]
export default defineNuxtConfig({
// Global
security: {
sri: true
}
})
```

You can disable the feature globally by setting `sri: false`.

## Usage

Subresource Integrity is used for two important security features of your application:

**1. SRI ensures that the assets that _you_ included in your build have not been altered.**

When you build your Nuxt application and deliver it to your users, a significant number of critical components are included in your final bundle.

These components are mostly scripts containing Javascript code (files such as `/_nuxt/entry.b8aef440d.js`), stylesheets, etc. An attacker may try to compromise your application by modifying these files.

Nuxt Security calculates the hash of each of these files _at build time_, therefore guaranteeing that the files that are loaded by your users are exactly the ones that you included in your bundle.

Arguably, if you host your static assets yourself, the risk that these files are modified by a malicious actor without your authorization can be rated as low.

However:

- If you host your application on a public CDN, that CDN could become the target of an attack.
- Even if you host your application on a private hosting service, you should be aware that most hosting providers use elaborate caching strategies to accelerate the delivery of your files (e.g. via edge CDN replication).
- In any case, your own account (or the account of one of the members of your organization) might become compromised.

For these reasons, most modern web applications rely on SRI to reduce their attack surface.

::alert{type="success"}
SRI is supported by all modern browsers: [caniuse](https://caniuse.com/subresource-integrity)
::

**2. SRI is a critical component of Content Security Policy (CSP) in SSG mode.**

For more information on the relationship between Subresource Integrity and a Strict CSP, please read our [Advanced Section on Integrity Hashes for CSP](/documentation/advanced/strict-csp/#ssg-mode)

If you use CSP on a statically-generated application, you will need to enable SRI by setting `sri: true`.

::alert{type="warning"}
Subresource Integrity hashes can only be inserted on `<script>` and `<link>` elements generated as part of the server bundle.
<br>
To protect your Nuxt application after client-side hydration, you must deploy a Strict CSP.
::
21 changes: 20 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
import { resolve, normalize } from 'pathe'
import { defineNuxtModule, addServerHandler, installModule, addVitePlugin } from '@nuxt/kit'
import { defu } from 'defu'
import type { Nuxt, RuntimeConfig } from '@nuxt/schema'
import type { Nuxt } from '@nuxt/schema'
import viteRemove from 'unplugin-remove/vite'
import { defuReplaceArray } from './utils'
import type {
Expand All @@ -20,6 +20,7 @@ import {
} from './defaultConfig'
import { SECURITY_MIDDLEWARE_NAMES } from './middlewares'
import { type HeaderMapper, SECURITY_HEADER_NAMES, getHeaderValueFromOptions } from './headers'
import sriHashes from './runtime/utils/sriHashes'

declare module 'nuxt/schema' {
interface NuxtOptions {
Expand Down Expand Up @@ -157,6 +158,13 @@ export default defineNuxtModule<ModuleOptions>({
})
}

// Calculates SRI hashes at build time
if (nuxt.options.security.sri) {
// At server build time, we calculate sri hashes
nuxt.hook('nitro:build:public-assets', sriHashes)

}

nuxt.hook('imports:dirs', (dirs) => {
dirs.push(normalize(resolve(runtimeDir, 'composables')))
})
Expand Down Expand Up @@ -260,6 +268,17 @@ const registerSecurityNitroPlugins = (
)
}

// Register nitro plugin to enable subresource integrity
if (securityOptions.sri) {
config.plugins.push(
normalize(
fileURLToPath(
new URL('./runtime/nitro/plugins/01m-subresourceIntegrity', import.meta.url)
)
)
)
}

// Register nitro plugin to enable CSP for SSG
if (
typeof securityOptions.headers === 'object' &&
Expand Down
86 changes: 86 additions & 0 deletions src/runtime/nitro/plugins/01m-subresourceIntegrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { H3Event } from 'h3'
import { extname } from 'pathe'
import { useStorage } from '#imports'
import * as cheerio from 'cheerio'

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', async (html, { event }) => {
const prerendering = isPrerendering(event)

// Retrieve the sriHases that we computed at build time
//
// - If we are in a pre-rendering step of nuxi generate
// Then the /integrity directory does not exist in server assets
// But it is still in the .nuxt build directory
//
// - Conversely, if we are in a standalone SSR server pre-built by nuxi build
// Then we don't have a .nuxt build directory anymore
// But we did save the /integrity directory into the server assets

const storageBase = prerendering ? 'build' : 'assets'
const sriHashes: Record<string, string> = await useStorage(storageBase).getItem('integrity:sriHashes.json') || {}

// Scan all relevant sections of the NuxtRenderHtmlContext
// Note: integrity can only be set on scripts and on links with rel preload, modulepreload and stylesheet
// However the SRI standard provides that other elements may be added to that list in the future
for (const section of ['body', 'bodyAppend', 'bodyPrepend', 'head']) {
const htmlRecords = html as unknown as Record<string, string[]>

htmlRecords[section] = htmlRecords[section].map(element => {
const $ = cheerio.load(element, null, false)
// Add integrity to all relevant script tags
$('script').each((i, script) => {
const scriptAttrs = $(script).attr()
const src = scriptAttrs?.src
const integrity = scriptAttrs?.integrity
// Only add integrity to external scripts that do not already have one
if (src && !integrity) {
// Get the integrity hash from our static database
const hash = sriHashes[src]
// Set the integrity hash in HTML if found
if (hash) {
$(script).attr('integrity', hash)
}
}
})
// Add integrity to all relevant link tags
$('link').each((i, link) => {
const linkAttrs = $(link).attr()
const href = linkAttrs?.href
const integrity = linkAttrs?.integrity
// Only add integrity to resources that do not already have one
if (href && !integrity) {
// Get the integrity hash from our static database
const hash = sriHashes[href]
// Set the integrity hash in HTML if found
if (hash) {
$(link).attr('integrity', hash)
}
}
})
return $.html()
})
}
})

/**
* Detect if page is being pre-rendered
* @param event H3Event
* @returns boolean
*/
function isPrerendering(event: H3Event): boolean {
const nitroPrerenderHeader = 'x-nitro-prerender'

// Page is not prerendered
if (!event.node.req.headers[nitroPrerenderHeader]) {
return false
}

// File is not HTML
if (!['', '.html'].includes(extname(event.node.req.headers[nitroPrerenderHeader] as string))) {
return false
}

return true
}
})
64 changes: 64 additions & 0 deletions src/runtime/utils/sriHashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createHash } from 'node:crypto'
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
import type { Nitro } from 'nitropack'
import { join } from 'pathe'


export default async function (nitro: Nitro) {
const hashAlgorithm = 'sha384'
const sriHashes: Record<string, string> = {}

// Will be later necessary to construct url
const { cdnURL: appCdnUrl = '', baseURL: appBaseUrl } = nitro.options.runtimeConfig.app


// Go through all public assets folder by folder
const publicAssets = nitro.options.publicAssets
for (const publicAsset of publicAssets) {
const { dir, baseURL = '' } = publicAsset

// Node 16 compatibility maintained
// Node 18.17+ supports recursive option on readdir
// const entries = await readdir(dir, { withFileTypes: true, recursive: true })
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile()) {

// Node 16 compatibility maintained
// Node 18.17+ supports entry.path on DirEnt
// const fullPath = join(entry.path, entry.name)
const fullPath = join(dir, entry.name)
const fileContent = await readFile(fullPath)
const hash = generateHash(fileContent, hashAlgorithm)
// construct the url as it will appear in the head template
const relativeUrl = join(baseURL, entry.name)
let url: string
if (appCdnUrl) {
// If the cdnURL option was set, the url will be in the form https://...
url = new URL(relativeUrl, appCdnUrl).href
} else {
// If not, the url will be in a relative form: /_nuxt/...
url = join('/', appBaseUrl, relativeUrl)
}
sriHashes[url] = hash
}
}
}

// Save hashes in a /integrity directory within the .nuxt build for later use with SSG
const buildDir = nitro.options.buildDir
const integrityDir = join(buildDir, 'integrity')
await mkdir(integrityDir)
const hashFilePath = join(integrityDir, 'sriHashes.json')
await writeFile(hashFilePath, JSON.stringify(sriHashes))

// Mount the /integrity directory into server assets for later use with SSR
nitro.options.serverAssets.push({ dir: integrityDir, baseName: 'integrity' })

}

function generateHash (content: Buffer, hashAlgorithm: string) {
const hash = createHash(hashAlgorithm)
hash.update(content)
return `${hashAlgorithm}-${hash.digest('base64')}`
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ModuleOptions {
nonce: boolean;
removeLoggers?: RemoveOptions | false;
ssg?: Ssg;
sri?: boolean
}

export interface NuxtSecurityRouteRules {
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/sri/.nuxtrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
imports.autoImport=true
Binary file added test/fixtures/sri/assets/snyk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions test/fixtures/sri/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({

modules: ['../../../src/module'],

// Per route configuration
routeRules: {
'/public': {
prerender: true,
}
},

// Global configuration
security: {
rateLimiter: false,
sri: true
},

})
5 changes: 5 additions & 0 deletions test/fixtures/sri/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "basic",
"type": "module"
}
7 changes: 7 additions & 0 deletions test/fixtures/sri/pages/asset.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<div>
Includes an image the ~/assets folder
<img src="~/assets/snyk.png">
</div>
</template>

15 changes: 15 additions & 0 deletions test/fixtures/sri/pages/external.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<div>
Includes 2 manually entered integrity hashes
</div>
</template>
<script setup>
useHead({
link: [
{rel:'stylesheet', href:'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css', integrity: 'sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN' }
],
script: [
{ src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js', integrity:'sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL', crossorigin: "" }
]
})
</script>
10 changes: 10 additions & 0 deletions test/fixtures/sri/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template>
<div>
{{ data }}
<NuxtLink to="/about">Go to about page</NuxtLink>
</div>
</template>

<script setup>
const { data } = await useAsyncData(() => 'Home')
</script>
14 changes: 14 additions & 0 deletions test/fixtures/sri/pages/public.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div>
Includes an icon from public folder and an image from public folder
<img src="/preview.png">
</div>
</template>
<script setup>
useHead({
link: [
{ rel: 'icon', href: '/icon.png' },
{ rel: 'preload', as: 'image', href: '/preview.png' }
]
})
</script>
Binary file added test/fixtures/sri/public/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/sri/public/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 64a2aa4

Please sign in to comment.