Skip to content

fix(sri): resolve hashes using cdnURL at runtime instead of build-time #615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
37 changes: 14 additions & 23 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver, addImportsDir, useNitro, addServerImports } from '@nuxt/kit'
import { existsSync } from 'node:fs'
import { readFile, readdir } from 'node:fs/promises'
import { join, isAbsolute } from 'pathe'

Check warning on line 4 in src/module.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

'isAbsolute' is defined but never used
import { defu } from 'defu'
import viteRemove from 'unplugin-remove/vite'
import { getHeadersApplicableToAllResources } from './utils/headers'
Expand All @@ -23,7 +23,7 @@
},
async setup (options, nuxt) {
const resolver = createResolver(import.meta.url)

nuxt.options.build.transpile.push(resolver.resolve('./runtime'))

// First merge module options with default options
Expand Down Expand Up @@ -79,7 +79,7 @@
} else {
// In case of esbuild, set the drop option
nuxt.options.vite.esbuild = defu(
{
{
drop: ['console', 'debugger'] as ('console' | 'debugger')[],
},
nuxt.options.vite.esbuild
Expand Down Expand Up @@ -109,7 +109,7 @@
)
}
}

// Register nitro plugin to manage security rules at the level of each route
addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules'))

Expand Down Expand Up @@ -160,12 +160,12 @@
addServerHandler({
handler: resolver.resolve('./runtime/server/middleware/rateLimiter')
})

// Register XSS validator middleware
addServerHandler({
handler: resolver.resolve('./runtime/server/middleware/xssValidator')
})

// Register basicAuth middleware that is disabled by default
const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth
if (basicAuthConfig && (basicAuthConfig.enabled || (basicAuthConfig as any)?.value?.enabled)) {
Expand Down Expand Up @@ -199,12 +199,12 @@
})

// Register init hook to add pre-rendered headers to responses
nuxt.hook('nitro:init', nitro => {
nuxt.hook('nitro:init', nitro => {
nitro.hooks.hook('prerender:done', async() => {
// Add the prenredered headers to the Nitro server assets
nitro.options.serverAssets.push({
baseName: 'nuxt-security',
dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security')
nitro.options.serverAssets.push({
baseName: 'nuxt-security',
dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security')
})

// In some Nitro presets (e.g. Vercel), the header rules are generated for the static server
Expand All @@ -228,7 +228,7 @@
})

/**
*
*
* Register storage driver for the rate limiter
*/
function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) {
Expand All @@ -254,8 +254,8 @@
* Make sure our nitro plugins will be applied last,
* After all other third-party modules that might have loaded their own nitro plugins
*/
function reorderNitroPlugins(nuxt: Nuxt) {
nuxt.hook('nitro:init', nitro => {
function reorderNitroPlugins(nuxt: Nuxt) {
nuxt.hook('nitro:init', nitro => {
const resolver = createResolver(import.meta.url)
const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins')

Expand Down Expand Up @@ -302,7 +302,7 @@
const sriHashes: Record<string, string> = {}

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

// Go through all public assets folder by folder
const publicAssets = nitro.options.publicAssets
Expand All @@ -325,16 +325,7 @@
const hash = await generateHash(content, hashAlgorithm)
// construct the url as it will appear in the head template
const fullPath = join(baseURL, entry.name)
let url: string
if (appCdnUrl) {
// If the cdnURL option was set, the url will be in the form https://...
const relativePath = isAbsolute(fullPath) ? fullPath.slice(1) : fullPath
const abdsoluteCdnUrl = appCdnUrl.endsWith('/') ? appCdnUrl : appCdnUrl + '/'
url = new URL(relativePath, abdsoluteCdnUrl).href
} else {
// If not, the url will be in a relative form: /_nuxt/...
url = join('/', appBaseUrl, fullPath)
}
const url = join('/', appBaseUrl, fullPath)
sriHashes[url] = hash
}
}
Expand Down
11 changes: 7 additions & 4 deletions src/runtime/nitro/plugins/20-subresourceIntegrity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineNitroPlugin } from '#imports'
import { defineNitroPlugin, useRuntimeConfig } from '#imports'
//@ts-expect-error : we are importing from the virtual file system
import sriHashes from '#sri-hashes'
import { resolveSecurityRules } from '../context'
Expand All @@ -17,6 +17,9 @@ export default defineNitroPlugin((nitroApp) => {
if (!rules.enabled || !rules.sri) {
return
}
const runtimeConfig = useRuntimeConfig()
const cdnURL = runtimeConfig.app.cdnURL.replace(/\/$/, '') // Remove trailing slash


// Scan all relevant sections of the NuxtRenderHtmlContext
// Note: integrity can only be set on scripts and on links with rel preload, modulepreload and stylesheet
Expand All @@ -28,9 +31,9 @@ export default defineNitroPlugin((nitroApp) => {
if (typeof element !== 'string') {
return element;
}

element = element.replace(SCRIPT_RE, (match, rest: string, src: string) => {
const hash = sriHashes[src]
const hash = sriHashes[src.replace(cdnURL, '')]
if (hash) {
const integrityScript = `<script integrity="${hash}"${rest}></script>`
return integrityScript
Expand All @@ -40,7 +43,7 @@ export default defineNitroPlugin((nitroApp) => {
})

element = element.replace(LINK_RE, (match, rest: string, href: string) => {
const hash = sriHashes[href]
const hash = sriHashes[href.replace(cdnURL, '')]
if (hash) {
const integrityLink = `<link integrity="${hash}"${rest}>`
return integrityLink
Expand Down
42 changes: 42 additions & 0 deletions test/sri-cdn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils'

describe('[nuxt-security] Subresource Integrity with cdnURL', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/sri', import.meta.url)),
nuxtConfig: {
app: {
cdnURL: 'https://cdn.example.com'
}
}
})

const expectedIntegrityAttributes = 3 // 2 links (entry, index), 1 script (entry)

it('correctly handles resources with cdnUrl when applying integrity hashes', async () => {
// Test the home page which should have script and link tags
const res = await fetch('/')
const text = await res.text()

// Verify that the response is valid
expect(res).toBeDefined()
expect(res).toBeTruthy()
expect(text).toBeDefined()

const elementsWithIntegrity = text.match(/<(script|link)[^>]*\s+integrity="sha384-[^"]*"[^>]*>/g) ?? [];

// Check that urls are correctly prefixed with the cdnURL
for (const element of elementsWithIntegrity) {
const urlMatch = element.match(/\s(?:src|href)="([^"]+)"/);
const url = urlMatch?.[1];

expect(url).toBeDefined();
expect(url).toMatch(/^https:\/\/cdn\.example\.com/);
}

// Check the number of elements with integrity attributes
expect(elementsWithIntegrity.length).toBe(expectedIntegrityAttributes + 1); // + nuxt-link

})
})