From aa9e765bef65cee1e8b39dda580c6b1ad80e6a4e Mon Sep 17 00:00:00 2001 From: Maxim Lepekha Date: Thu, 3 Apr 2025 14:16:57 +0200 Subject: [PATCH 1/3] fix: use cdnURL for cdnURL in runtime --- src/module.ts | 37 +++++++------------ .../nitro/plugins/20-subresourceIntegrity.ts | 11 ++++-- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/module.ts b/src/module.ts index 674168d0..b6181aed 100644 --- a/src/module.ts +++ b/src/module.ts @@ -23,7 +23,7 @@ export default defineNuxtModule({ }, 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 @@ -79,7 +79,7 @@ export default defineNuxtModule({ } else { // In case of esbuild, set the drop option nuxt.options.vite.esbuild = defu( - { + { drop: ['console', 'debugger'] as ('console' | 'debugger')[], }, nuxt.options.vite.esbuild @@ -109,7 +109,7 @@ export default defineNuxtModule({ ) } } - + // Register nitro plugin to manage security rules at the level of each route addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules')) @@ -160,12 +160,12 @@ export default defineNuxtModule({ 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)) { @@ -199,12 +199,12 @@ export default defineNuxtModule({ }) // 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 @@ -228,7 +228,7 @@ export default defineNuxtModule({ }) /** - * + * * Register storage driver for the rate limiter */ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) { @@ -254,8 +254,8 @@ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) * 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') @@ -302,7 +302,7 @@ async function hashBundledAssets(nitro: Nitro) { const sriHashes: Record = {} // 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 @@ -325,16 +325,7 @@ async function hashBundledAssets(nitro: Nitro) { 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 } } diff --git a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts index cd836c07..baefdaac 100644 --- a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts @@ -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' @@ -17,6 +17,9 @@ export default defineNitroPlugin((nitroApp) => { if (!rules.enabled || !rules.sri) { return } + const runtimeConfig = useRuntimeConfig() + const cdnURL = runtimeConfig.app.cdnURL + // Scan all relevant sections of the NuxtRenderHtmlContext // Note: integrity can only be set on scripts and on links with rel preload, modulepreload and stylesheet @@ -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 = `` return integrityScript @@ -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 = `` return integrityLink From 283ecb7bd67c590ffd6c68929aeeb6a92c6da66f Mon Sep 17 00:00:00 2001 From: Maxim Lepekha Date: Mon, 5 May 2025 17:08:11 +0200 Subject: [PATCH 2/3] fix: del trailing slash from cdnURL --- src/runtime/nitro/plugins/20-subresourceIntegrity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts index baefdaac..16f2aa39 100644 --- a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts @@ -18,7 +18,7 @@ export default defineNitroPlugin((nitroApp) => { return } const runtimeConfig = useRuntimeConfig() - const cdnURL = runtimeConfig.app.cdnURL + const cdnURL = runtimeConfig.app.cdnURL.replace(/\/$/, '') // Remove trailing slash // Scan all relevant sections of the NuxtRenderHtmlContext From 7cd2b036d0bb0123cd7df7111cbb898d5d3fc7d8 Mon Sep 17 00:00:00 2001 From: Maxim Lepekha Date: Mon, 5 May 2025 17:55:43 +0200 Subject: [PATCH 3/3] test: sri+cdn url unit test --- test/sri-cdn.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/sri-cdn.test.ts diff --git a/test/sri-cdn.test.ts b/test/sri-cdn.test.ts new file mode 100644 index 00000000..d11bd466 --- /dev/null +++ b/test/sri-cdn.test.ts @@ -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 + + }) +})