From f35efead643826aa75b48abe4379f643835b6727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Thu, 4 Apr 2024 08:43:19 +0200 Subject: [PATCH 01/21] wip on unified context --- playground/nuxt.config.ts | 2 +- playground/server/api/test.post.ts | 4 +- src/module.ts | 189 ++++++------------ src/runtime/nitro/plugins/00-context.ts | 164 +++++++++++---- .../nitro/plugins/02-securityHeaders.ts | 2 +- src/runtime/nitro/plugins/99-cspSsrNonce.ts | 31 +-- .../server/middleware/cspNonceHandler.ts | 3 +- 7 files changed, 199 insertions(+), 196 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index a9b29864..9634ebfb 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -5,7 +5,7 @@ export default defineNuxtConfig({ // Per route configuration routeRules: { - secret: { + '/secret': { security: { rateLimiter: false }, diff --git a/playground/server/api/test.post.ts b/playground/server/api/test.post.ts index 4f231748..e8017027 100644 --- a/playground/server/api/test.post.ts +++ b/playground/server/api/test.post.ts @@ -1,3 +1,3 @@ -export default defineEventHandler((event) => { - console.log('test') +export default defineEventHandler(async (event) => { + console.log('api test', event.path, event) }) diff --git a/src/module.ts b/src/module.ts index c5692a59..ba98f76c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,6 +1,4 @@ -import { fileURLToPath } from 'node:url' -import { resolve, normalize } from 'pathe' -import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin } from '@nuxt/kit' +import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver } from '@nuxt/kit' import { defu } from 'defu' import type { Nuxt } from '@nuxt/schema' import viteRemove from 'unplugin-remove/vite' @@ -48,8 +46,9 @@ export default defineNuxtModule({ configKey: 'security' }, async setup (options, nuxt) { - const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url)) - nuxt.options.build.transpile.push(runtimeDir) + const resolver = createResolver(import.meta.url) + const runtimeDir = resolver.resolve('./runtime') + nuxt.options.build.transpile.push(resolver.resolve('./runtime')) nuxt.options.security = defuReplaceArray( { ...options, ...nuxt.options.security }, { @@ -64,8 +63,6 @@ export default defineNuxtModule({ addVitePlugin(viteRemove(securityOptions.removeLoggers)) } - registerSecurityNitroPlugins(nuxt, securityOptions) - nuxt.options.runtimeConfig.private = defu( nuxt.options.runtimeConfig.private, { @@ -85,27 +82,26 @@ export default defineNuxtModule({ // PER ROUTE OPTIONS setGlobalSecurityRoute(nuxt, securityOptions) - mergeSecurityPerRoute(nuxt) + //mergeSecurityPerRoute(nuxt) + +/* addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/cspNonceHandler') - ) + handler: resolver.resolve('./runtime/server/middleware/cspNonceHandler') }) + + */ if (nuxt.options.security.requestSizeLimiter) { addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/requestSizeLimiter') - ) + handler: resolver.resolve('./runtime/server/middleware/requestSizeLimiter') }) } if (nuxt.options.security.rateLimiter) { + registerStorageDriver(nuxt, securityOptions) addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/rateLimiter') - ) + handler: resolver.resolve('./runtime/server/middleware/rateLimiter') }) } @@ -113,25 +109,54 @@ export default defineNuxtModule({ // Remove potential duplicates nuxt.options.security.xssValidator.methods = Array.from(new Set(nuxt.options.security.xssValidator.methods)) addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/xssValidator') - ) + handler: resolver.resolve('./runtime/server/middleware/xssValidator') }) } if (nuxt.options.security.corsHandler) { addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/corsHandler') - ) + handler: resolver.resolve('./runtime/server/middleware/corsHandler') }) } if(nuxt.options.security.runtimeHooks) { - addServerPlugin(resolve(runtimeDir, 'nitro/plugins/00-context')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-context')) + } + + if (securityOptions.hidePoweredBy) { + nuxt.options.nitro.externals = nuxt.options.nitro.externals || {} + nuxt.options.nitro.externals.inline = nuxt.options.nitro.externals.inline || [] + nuxt.options.nitro.externals.inline.push(runtimeDir) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/01-hidePoweredBy')) } + // Register nitro plugin to enable Security Headers + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02-securityHeaders')) + + // Pre-process HTML into DOM tree + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02a-preprocessHtml')) + + // Register nitro plugin to enable Subresource Integrity + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/03-subresourceIntegrity')) + + // Register nitro plugin to enable CSP Hashes for SSG + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/04-cspSsgHashes')) + + // Register nitro plugin to enable CSP Headers presets for SSG + // TEMPORARILY DISABLED AS NUXT 3.9.3 PREVENTS IMPORTING @NUXT/KIT IN NITRO PLUGINS + /* + addServerPlugin(resolve('./runtime/nitro/plugins/05-cspSsgPresets')) + */ + + // Nitro plugin to enable CSP Nonce for SSR + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99-cspSsrNonce')) + + + // Recombine HTML from DOM tree + // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99b-recombineHtml')) + + const allowedMethodsRestricterConfig = nuxt.options.security .allowedMethodsRestricter if ( @@ -139,9 +164,7 @@ export default defineNuxtModule({ !Object.values(allowedMethodsRestricterConfig).includes('*') ) { addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/allowedMethodsRestricter') - ) + handler: resolver.resolve('./runtime/server/middleware/allowedMethodsRestricter') }) } @@ -151,18 +174,19 @@ export default defineNuxtModule({ if (basicAuthConfig && ((basicAuthConfig as any)?.enabled || (basicAuthConfig as any)?.value?.enabled)) { addServerHandler({ route: (basicAuthConfig as any).route || '', - handler: normalize(resolve(runtimeDir, 'server/middleware/basicAuth')) + handler: resolver.resolve('./runtime/server/middleware/basicAuth') }) } // Calculates SRI hashes at build time nuxt.hook('nitro:build:before', hashBundledAssets) - + // Import composables nuxt.hook('imports:dirs', (dirs) => { - dirs.push(normalize(resolve(runtimeDir, 'composables'))) + dirs.push(resolver.resolve('./runtime/composables')) }) + // Import CSURF module const csrfConfig = nuxt.options.security.csrf if (csrfConfig) { if (Object.keys(csrfConfig).length) { @@ -176,8 +200,14 @@ export default defineNuxtModule({ // Adds the global security options to all routes function setGlobalSecurityRoute(nuxt: Nuxt, securityOptions: ModuleOptions) { nuxt.options.nitro.routeRules = defuReplaceArray( - { '/**': { security: securityOptions }}, - nuxt.options.nitro.routeRules + { + '/**': { security: securityOptions } + }, + nuxt.options.nitro.routeRules, + { + '/api/**' : { security: { headers: false } as { headers: false } }, + '/_nuxt/**' : { security : { headers: false } as { headers: false } } + }, ) } @@ -253,10 +283,8 @@ function mergeSecurityPerRoute(nuxt: Nuxt) { } -function registerSecurityNitroPlugins(nuxt: Nuxt, securityOptions: ModuleOptions) { +function registerStorageDriver(nuxt: Nuxt, securityOptions: ModuleOptions) { nuxt.hook('nitro:config', (config) => { - config.plugins = config.plugins || [] - if (securityOptions.rateLimiter) { // setup unstorage const driver = (securityOptions.rateLimiter).driver @@ -273,99 +301,14 @@ function registerSecurityNitroPlugins(nuxt: Nuxt, securityOptions: ModuleOptions ) } } - - // Register nitro plugin to replace default 'X-Powered-By' header with custom one that does not indicate what is the framework underneath the app. - if (securityOptions.hidePoweredBy) { - config.externals = config.externals || {} - config.externals.inline = config.externals.inline || [] - config.externals.inline.push( - normalize(fileURLToPath(new URL('./runtime', import.meta.url))) - ) - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/01-hidePoweredBy', import.meta.url) - ) - ) - ) - } - - // Register nitro plugin to enable Security Headers - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/02-securityHeaders', import.meta.url) - ) - ) - ) - - // Pre-process HTML into DOM tree - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/02a-preprocessHtml', import.meta.url) - ) - ) - ) - - // Register nitro plugin to enable Subresource Integrity - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/03-subresourceIntegrity', import.meta.url) - ) - ) - ) - - // Register nitro plugin to enable CSP Hashes for SSG - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/04-cspSsgHashes', import.meta.url) - ) - ) - ) - - // Register nitro plugin to enable CSP Headers presets for SSG - // TEMPORARILY DISABLED AS NUXT 3.9.3 PREVENTS IMPORTING @NUXT/KIT IN NITRO PLUGINS - /* - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/05-cspSsgPresets', import.meta.url) - ) - ) - ) - */ - - // Nitro plugin to enable CSP Nonce for SSR - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/99-cspSsrNonce', import.meta.url) - ) - ) - ) - - - // Recombine HTML from DOM tree - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/99b-recombineHtml', import.meta.url) - ) - ) - ) }) // Make sure our nitro plugins will be applied last // After all other third-party modules that might have loaded their own nitro plugins nuxt.hook('nitro:init', nitro => { - const securityPluginsPrefix = normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins', import.meta.url) - ) - ) + const resolver = createResolver(import.meta.url) + const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') + // SSR: Reorder plugins in Nitro options nitro.options.plugins.sort((a, b) => { if (a.startsWith(securityPluginsPrefix)) { diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index 098906cc..21c03ee7 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -1,48 +1,134 @@ -import { getNameFromKey, headerStringFromObject} from "../../utils/headers" -import { createRouter} from "radix3" -import { defineNitroPlugin, setHeader, removeResponseHeader} from "#imports" -import { OptionKey } from "~/src/module" +import { getNameFromKey, headerStringFromObject, headerObjectFromString, getKeyFromName } from "../../utils/headers" +import { createRouter } from "radix3" +import { defineNitroPlugin, setResponseHeader, removeResponseHeader, getRouteRules } from "#imports" +import { ContentSecurityPolicyValue, OptionKey, SecurityHeaders } from "~/src/module" +import { defuReplaceArray } from "../../../utils" +import crypto from 'node:crypto' +import type { H3Event } from "h3" export default defineNitroPlugin((nitroApp) => { - const router = createRouter>() - - nitroApp.hooks.hook('nuxt-security:headers', ({route, headers: headersConfig}) => { - const headers: Record = {} - - for (const [header, headerOptions] of Object.entries(headersConfig)) { - const headerName = getNameFromKey(header as OptionKey) - if(headerName) { - const value = headerStringFromObject(header as OptionKey, headerOptions) - if(value) { - headers[headerName] = value - } else { - delete headers[headerName] - } - } + + const router = createRouter() + + nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { + router.insert(route, headers) + }) + + + nitroApp.hooks.hook('request', (event) => { + console.log('request context', event.path, event) + const configRouteRules = getRouteRules(event) + const { security, headers: standardHeaders } = configRouteRules + + if (security?.nonce) { + const nonce = crypto.randomBytes(16).toString('base64') + event.context.nonce = nonce + } + + // STEP 1 - DETECT STANDARD HEADERS THAT MAY OVERLAP WITH SECURITY HEADERS + // Lookup standard radix headers + // Detect if they belong to one of the SecurityHeaders + // And convert them into object format + const standardHeadersAsObject: SecurityHeaders = {} + + if (standardHeaders) { + Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { + const optionKey = getKeyFromName(headerName) + if (optionKey) { + if (typeof headerValue === 'string') { + // Normally, standard radix headers should be supplied as string + const objectValue: any = headerObjectFromString(optionKey, headerValue) + standardHeadersAsObject[optionKey] = objectValue + } else { + // Here we ensure backwards compatibility + // Because in the pre-rc1 syntax, standard headers could also be supplied in object format + standardHeadersAsObject[optionKey] = headerValue + standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) + } } + }) + } - router.insert(route, headers) - }) + // STEP 2 - ENSURE BACKWARDS COMPATIBILITY OF SECURITY HEADERS + // Lookup the Security headers, normally they should be in object format + // However detect if they were supplied in string format + // And convert them into object format + const securityHeadersAsObject: SecurityHeaders = {} - nitroApp.hooks.hook('request', (event) => { - event.context.security = event.context.security || {} - const routeSecurity = router.lookup(event.path) as Record - if(routeSecurity) { - event.context.security.headers = routeSecurity + if (security?.headers) { + const { headers: securityHeaders } = security + Object.entries(securityHeaders).forEach(([key, value]) => { + const optionKey = key as OptionKey + if ((optionKey === 'contentSecurityPolicy' || optionKey === 'permissionsPolicy' || optionKey === 'strictTransportSecurity') && (typeof value === 'string')) { + // Altough this does not make sense in post-rc1 typescript definitions + // It was possible before rc1 though, so let's ensure backwards compatibility here + const objectValue: any = headerObjectFromString(optionKey, value) + securityHeadersAsObject[optionKey] = objectValue + } else if (value === '') { + securityHeadersAsObject[optionKey] = false + } else { + securityHeadersAsObject[optionKey] = value } - }) - - nitroApp.hooks.hook('beforeResponse', (event) => { - if(event.context.security.headers) { - Object.entries(event.context.security.headers).forEach(([header, value]) => { - if (value === false) { - removeResponseHeader(event, header) - } else { - setHeader(event, header, value) - } - }) + }) + } + + // STEP 3 - RETRIEVE RUNTIME HEADERS + const runtimeHeaders = router.lookup(event.path) || {} + + // STEP 4 - MERGE STANDARD HEADERS, SECURITY HEADERS AND RUNTIME HEADERS, IN THAT ORDER + const mergedHeaders = defuReplaceArray(runtimeHeaders, securityHeadersAsObject, standardHeadersAsObject) + + event.context.security = { + headers: mergedHeaders + } + }) + + nitroApp.hooks.hook('beforeResponse', (event) => { + console.log('beforeResponse', event.path, event) + const nonce = event.context.nonce as string + const headers = event.context.security.headers + + if (headers && headers.contentSecurityPolicy) { + const csp = headers.contentSecurityPolicy + headers.contentSecurityPolicy = insertNonceInCsp(csp, nonce) + } + if (headers) { + Object.entries(headers).forEach(([header, value]) => { + const headerName = getNameFromKey(header as OptionKey) + if (value === false) { + removeResponseHeader(event, headerName) + } else { + const headerValue = headerStringFromObject(header as OptionKey, value) + setResponseHeader(event, headerName, headerValue) } - }) + }) + } + }) + + + function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { + const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { + // Return boolean values unchanged + if (typeof value === 'boolean') { + return [directive, value] + } + // Make sure nonce placeholders are eliminated + const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value + const modifiedSources = sources + .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") + .map(source => { + if (source === "'nonce-{{nonce}}'") { + return nonce ? `'nonce-${nonce}'` : '' + } else { + return source + } + }) + .filter(source => source) + + return [directive, modifiedSources] + })) + return generatedCsp as ContentSecurityPolicyValue + } - nitroApp.hooks.callHook('nuxt-security:ready') + nitroApp.hooks.callHook('nuxt-security:ready') }) diff --git a/src/runtime/nitro/plugins/02-securityHeaders.ts b/src/runtime/nitro/plugins/02-securityHeaders.ts index b41807a8..3585461e 100644 --- a/src/runtime/nitro/plugins/02-securityHeaders.ts +++ b/src/runtime/nitro/plugins/02-securityHeaders.ts @@ -6,7 +6,7 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (_, {event}) => { const { security } = getRouteRules(event) if (security?.headers) { - const { headers } = security + const headers = event.context.security.headers Object.entries(headers).forEach(([key, optionValue]) => { const optionKey = key as OptionKey const headerName = getNameFromKey(optionKey) diff --git a/src/runtime/nitro/plugins/99-cspSsrNonce.ts b/src/runtime/nitro/plugins/99-cspSsrNonce.ts index 7f4af4f1..83a7bec5 100644 --- a/src/runtime/nitro/plugins/99-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/99-cspSsrNonce.ts @@ -1,7 +1,7 @@ import { defineNitroPlugin, getRouteRules, setResponseHeader, getResponseHeaders } from '#imports' import { type CheerioAPI } from 'cheerio' import { isPrerendering } from '../utils' -import type { H3Event } from "h3" + export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (html, { event }) => { @@ -39,32 +39,5 @@ export default defineNitroPlugin((nitroApp) => { }) - nitroApp.hooks.hook('beforeResponse', (event) => { - const nonce = event.context.nonce as string - // Exit if no CSP defined - const { security } = getRouteRules(event) - if (!security?.headers || !security.headers.contentSecurityPolicy) { - return - } - - setNonceInCsp(event, nonce) - }) - - // Insert hashes in the CSP meta tag for both the script-src and the style-src policies - function setNonceInCsp(event: H3Event, nonce?: string) { - const headers = getResponseHeaders(event) - if (!headers['content-security-policy']) { - return - } - const newCspHeader = headers['content-security-policy'].split('; ').map(token => token.split(' ').filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") - .map(source => { - source = source.trim() - if (source === "'nonce-{{nonce}}'") { - return nonce ? `'nonce-${nonce}'` : '' - } else { - return source - } - }).filter(source => source).join(' ')).join('; ') - setResponseHeader(event, 'Content-Security-Policy', newCspHeader) - } + }) diff --git a/src/runtime/server/middleware/cspNonceHandler.ts b/src/runtime/server/middleware/cspNonceHandler.ts index 8e10491d..b5521d69 100644 --- a/src/runtime/server/middleware/cspNonceHandler.ts +++ b/src/runtime/server/middleware/cspNonceHandler.ts @@ -3,9 +3,10 @@ import { getRouteRules, defineEventHandler } from '#imports' export default defineEventHandler((event) => { const { security } = getRouteRules(event) + console.log('serverMiddleware', event.path, event) if (security?.nonce) { const nonce = crypto.randomBytes(16).toString('base64') - event.context.nonce = nonce + //event.context.nonce = nonce } }) From c979e32ac42858864f7640712005704b10f0c5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Wed, 10 Apr 2024 15:02:32 +0200 Subject: [PATCH 02/21] Working POC This now works by: - fetching rules in context from useRuntimeConfig() - ensuring event context is read at the right level by hooking in render:response rather than beforeResponse --- playground/nuxt.config.ts | 10 +- playground/server/api/test.post.ts | 2 +- src/module.ts | 16 +- src/runtime/nitro/plugins/00-context.ts | 219 +++++++++++------- .../nitro/plugins/02a-preprocessHtml.ts | 4 +- .../nitro/plugins/03-subresourceIntegrity.ts | 4 +- src/runtime/nitro/plugins/04-cspSsgHashes.ts | 12 +- src/runtime/nitro/plugins/99-cspSsrNonce.ts | 8 +- .../nitro/plugins/99b-recombineHtml.ts | 4 +- .../server/middleware/cspNonceHandler.ts | 2 +- src/types/headers.ts | 5 +- 11 files changed, 171 insertions(+), 115 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9634ebfb..bf49c50d 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -7,7 +7,12 @@ export default defineNuxtConfig({ routeRules: { '/secret': { security: { - rateLimiter: false + rateLimiter: false, + headers: { + strictTransportSecurity: { + preload: true + } + } }, headers: { 'X-XSS-Protection': '1' @@ -24,6 +29,7 @@ export default defineNuxtConfig({ tokensPerInterval: 10, interval: 10000 }, - runtimeHooks: true + runtimeHooks: true, + removeLoggers: false } }) diff --git a/playground/server/api/test.post.ts b/playground/server/api/test.post.ts index e8017027..3dc929fa 100644 --- a/playground/server/api/test.post.ts +++ b/playground/server/api/test.post.ts @@ -1,3 +1,3 @@ export default defineEventHandler(async (event) => { - console.log('api test', event.path, event) + console.log('api test', event.path, event.context.security) }) diff --git a/src/module.ts b/src/module.ts index ba98f76c..ce97be32 100644 --- a/src/module.ts +++ b/src/module.ts @@ -81,7 +81,7 @@ export default defineNuxtModule({ // PER ROUTE OPTIONS - setGlobalSecurityRoute(nuxt, securityOptions) + //setGlobalSecurityRoute(nuxt, securityOptions) //mergeSecurityPerRoute(nuxt) @@ -135,13 +135,13 @@ export default defineNuxtModule({ // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02-securityHeaders')) // Pre-process HTML into DOM tree - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02a-preprocessHtml')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02a-preprocessHtml')) // Register nitro plugin to enable Subresource Integrity - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/03-subresourceIntegrity')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/03-subresourceIntegrity')) // Register nitro plugin to enable CSP Hashes for SSG - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/04-cspSsgHashes')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/04-cspSsgHashes')) // Register nitro plugin to enable CSP Headers presets for SSG // TEMPORARILY DISABLED AS NUXT 3.9.3 PREVENTS IMPORTING @NUXT/KIT IN NITRO PLUGINS @@ -150,11 +150,11 @@ export default defineNuxtModule({ */ // Nitro plugin to enable CSP Nonce for SSR - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99-cspSsrNonce')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99-cspSsrNonce')) // Recombine HTML from DOM tree - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99b-recombineHtml')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99b-recombineHtml')) const allowedMethodsRestricterConfig = nuxt.options.security @@ -204,10 +204,10 @@ function setGlobalSecurityRoute(nuxt: Nuxt, securityOptions: ModuleOptions) { '/**': { security: securityOptions } }, nuxt.options.nitro.routeRules, - { + /*{ '/api/**' : { security: { headers: false } as { headers: false } }, '/_nuxt/**' : { security : { headers: false } as { headers: false } } - }, + },*/ ) } diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index 21c03ee7..7608b655 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -1,62 +1,42 @@ import { getNameFromKey, headerStringFromObject, headerObjectFromString, getKeyFromName } from "../../utils/headers" -import { createRouter } from "radix3" -import { defineNitroPlugin, setResponseHeader, removeResponseHeader, getRouteRules } from "#imports" -import { ContentSecurityPolicyValue, OptionKey, SecurityHeaders } from "~/src/module" +import { createRouter, toRouteMatcher } from "radix3" +import { defineNitroPlugin, setResponseHeader, removeResponseHeader, getRouteRules, useRuntimeConfig } from "#imports" +import { ContentSecurityPolicyValue, OptionKey, SecurityHeaders, NuxtSecurityRouteRules } from "~/src/module" import { defuReplaceArray } from "../../../utils" import crypto from 'node:crypto' import type { H3Event } from "h3" - -export default defineNitroPlugin((nitroApp) => { - - const router = createRouter() - - nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { - router.insert(route, headers) - }) - - - nitroApp.hooks.hook('request', (event) => { - console.log('request context', event.path, event) - const configRouteRules = getRouteRules(event) - const { security, headers: standardHeaders } = configRouteRules - - if (security?.nonce) { - const nonce = crypto.randomBytes(16).toString('base64') - event.context.nonce = nonce - } - - // STEP 1 - DETECT STANDARD HEADERS THAT MAY OVERLAP WITH SECURITY HEADERS - // Lookup standard radix headers - // Detect if they belong to one of the SecurityHeaders - // And convert them into object format - const standardHeadersAsObject: SecurityHeaders = {} - - if (standardHeaders) { - Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { - const optionKey = getKeyFromName(headerName) - if (optionKey) { - if (typeof headerValue === 'string') { - // Normally, standard radix headers should be supplied as string - const objectValue: any = headerObjectFromString(optionKey, headerValue) - standardHeadersAsObject[optionKey] = objectValue - } else { - // Here we ensure backwards compatibility - // Because in the pre-rc1 syntax, standard headers could also be supplied in object format - standardHeadersAsObject[optionKey] = headerValue - standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) - } +import { Nuxt } from "@nuxt/schema" +import defu from "defu" + +function standardToSecurity(standardHeaders?: Record) { + const standardHeadersAsObject: SecurityHeaders = {} + + if (standardHeaders) { + Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { + const optionKey = getKeyFromName(headerName) + if (optionKey) { + if (typeof headerValue === 'string') { + // Normally, standard radix headers should be supplied as string + const objectValue: any = headerObjectFromString(optionKey, headerValue) + standardHeadersAsObject[optionKey] = objectValue + } else { + // Here we ensure backwards compatibility + // Because in the pre-rc1 syntax, standard headers could also be supplied in object format + standardHeadersAsObject[optionKey] = headerValue + standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) } - }) - } + } + }) + return standardHeadersAsObject + } else { + return undefined + } +} - // STEP 2 - ENSURE BACKWARDS COMPATIBILITY OF SECURITY HEADERS - // Lookup the Security headers, normally they should be in object format - // However detect if they were supplied in string format - // And convert them into object format +function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders) { const securityHeadersAsObject: SecurityHeaders = {} - if (security?.headers) { - const { headers: securityHeaders } = security + if (securityHeaders) { Object.entries(securityHeaders).forEach(([key, value]) => { const optionKey = key as OptionKey if ((optionKey === 'contentSecurityPolicy' || optionKey === 'permissionsPolicy' || optionKey === 'strictTransportSecurity') && (typeof value === 'string')) { @@ -70,23 +50,115 @@ export default defineNitroPlugin((nitroApp) => { securityHeadersAsObject[optionKey] = value } }) + return securityHeadersAsObject + } else { + return undefined + } +} + +function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { + const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { + // Return boolean values unchanged + if (typeof value === 'boolean') { + return [directive, value] + } + // Make sure nonce placeholders are eliminated + const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value + const modifiedSources = sources + .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") + .map(source => { + if (source === "'nonce-{{nonce}}'") { + return nonce ? `'nonce-${nonce}'` : '' + } else { + return source + } + }) + .filter(source => source) + + return [directive, modifiedSources] + })) + return generatedCsp as ContentSecurityPolicyValue +} + +export default defineNitroPlugin((nitroApp) => { + + const config = useRuntimeConfig() + + const securityRouteRules: Record> = {} + // TO DO: CHECK POTENTIAL POLLUTION OF HEADERS OBJECT HERE + + // First insert standard route rules headers + for (const route in config.nitro.routeRules) { + const rule = config.nitro.routeRules[route] + const { headers } = rule + const securityHeaders = standardToSecurity(headers) + securityRouteRules[route] = { headers: securityHeaders } + } + + + // Then insert global security config + const securityOptions = config.security + securityRouteRules['/**'] = securityOptions + //securityRouteRules['/api/**'] = { headers: false } + //securityRouteRules['/_nuxt/**'] = { headers: false } + + + // Then insert route specific security headers + for (const route in config.nitro.routeRules) { + const rule = config.nitro.routeRules[route] + const { security } = rule + if (security) { + const { headers } = security + if (headers) { + const securityHeaders = backwardsCompatibleSecurity(headers) + securityRouteRules[route] = defu( + { headers: securityHeaders }, + securityRouteRules[route], + ) + } } + } + + const router = createRouter>({ + routes: securityRouteRules, + }) + + + nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { + securityRouteRules[route] = defu( + { headers }, + securityRouteRules[route] + ) + router.insert(route, securityRouteRules[route]) + }) - // STEP 3 - RETRIEVE RUNTIME HEADERS - const runtimeHeaders = router.lookup(event.path) || {} - // STEP 4 - MERGE STANDARD HEADERS, SECURITY HEADERS AND RUNTIME HEADERS, IN THAT ORDER - const mergedHeaders = defuReplaceArray(runtimeHeaders, securityHeadersAsObject, standardHeadersAsObject) + nitroApp.hooks.hook('request', (event) => { + + const matcher = toRouteMatcher(router) + const matches = matcher.matchAll(event.path) + const rules: Partial = defu({}, ...matches.reverse()) - event.context.security = { - headers: mergedHeaders + event.context.security = { rules } + + if (rules.nonce) { + const nonce = crypto.randomBytes(16).toString('base64') + event.context.security.nonce = nonce } + + console.log('request', event.path, event.context.security.nonce, event.context.security.rules) + + }) +/* + nitroApp.hooks.hook('render:response', (response, { event }) => { + //console.log('render:response', event.path, event.context.security.counter, event.context.security.nonce, event.context.security.rules.headers) }) +*/ + nitroApp.hooks.hook('render:response', (response, { event }) => { - nitroApp.hooks.hook('beforeResponse', (event) => { - console.log('beforeResponse', event.path, event) - const nonce = event.context.nonce as string - const headers = event.context.security.headers + const nonce = event.context.security.nonce + const headers = { ...event.context.security.rules.headers } + console.log('render:response', event.path, nonce, headers) if (headers && headers.contentSecurityPolicy) { const csp = headers.contentSecurityPolicy @@ -105,30 +177,5 @@ export default defineNitroPlugin((nitroApp) => { } }) - - function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { - const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { - // Return boolean values unchanged - if (typeof value === 'boolean') { - return [directive, value] - } - // Make sure nonce placeholders are eliminated - const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value - const modifiedSources = sources - .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") - .map(source => { - if (source === "'nonce-{{nonce}}'") { - return nonce ? `'nonce-${nonce}'` : '' - } else { - return source - } - }) - .filter(source => source) - - return [directive, modifiedSources] - })) - return generatedCsp as ContentSecurityPolicyValue - } - nitroApp.hooks.callHook('nuxt-security:ready') }) diff --git a/src/runtime/nitro/plugins/02a-preprocessHtml.ts b/src/runtime/nitro/plugins/02a-preprocessHtml.ts index beba00e5..817e93b0 100644 --- a/src/runtime/nitro/plugins/02a-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/02a-preprocessHtml.ts @@ -6,8 +6,8 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async (html, { event }) => { // Exit if no need to parse HTML for this route - const { security } = getRouteRules(event) - if (!security?.sri && (!security?.headers || !security?.headers.contentSecurityPolicy)) { + const { rules } = event.context.security + if (!rules?.sri && (!rules?.headers || !rules?.headers.contentSecurityPolicy)) { return } diff --git a/src/runtime/nitro/plugins/03-subresourceIntegrity.ts b/src/runtime/nitro/plugins/03-subresourceIntegrity.ts index e431a6c7..038bd9d0 100644 --- a/src/runtime/nitro/plugins/03-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/03-subresourceIntegrity.ts @@ -6,8 +6,8 @@ import { type CheerioAPI } from 'cheerio' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async (html, { event }) => { // Exit if SRI not enabled for this route - const { security } = getRouteRules(event) - if (!security?.sri) { + const { rules } = event.context.security + if (!rules?.sri) { return } diff --git a/src/runtime/nitro/plugins/04-cspSsgHashes.ts b/src/runtime/nitro/plugins/04-cspSsgHashes.ts index 7ecf8b00..3f8bc4fd 100644 --- a/src/runtime/nitro/plugins/04-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/04-cspSsgHashes.ts @@ -14,8 +14,8 @@ export default defineNitroPlugin((nitroApp) => { } // Exit if no CSP defined - const { security } = getRouteRules(event) - if (!security?.headers || !security.headers.contentSecurityPolicy) { + const { rules } = event.context.security + if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } @@ -26,8 +26,8 @@ export default defineNitroPlugin((nitroApp) => { const cheerios = event.context.cheerios as Record[]> // Parse HTML if SSG is enabled for this route - if (security.ssg) { - const { hashScripts, hashStyles } = security.ssg + if (rules.ssg) { + const { hashScripts, hashStyles } = rules.ssg // Scan all relevant sections of the NuxtRenderHtmlContext const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] @@ -99,11 +99,11 @@ export default defineNitroPlugin((nitroApp) => { } // Generate CSP rules - const csp = security.headers.contentSecurityPolicy + const csp = rules.headers.contentSecurityPolicy const headerValue = generateCspRules(csp, scriptHashes, styleHashes) // Insert CSP in the http meta tag if meta is true - if (security.ssg && security.ssg.meta) { + if (rules.ssg && rules.ssg.meta) { cheerios.head.push(cheerio.load(``, null, false)) } // Update rules in HTTP header diff --git a/src/runtime/nitro/plugins/99-cspSsrNonce.ts b/src/runtime/nitro/plugins/99-cspSsrNonce.ts index 83a7bec5..952e41ce 100644 --- a/src/runtime/nitro/plugins/99-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/99-cspSsrNonce.ts @@ -11,16 +11,16 @@ export default defineNitroPlugin((nitroApp) => { } // Exit if no CSP defined - const { security } = getRouteRules(event) - if (!security?.headers || !security.headers.contentSecurityPolicy) { + const { rules } = event.context.security + if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } let nonce: string | undefined; // Parse HTML if nonce is enabled for this route - if (security.nonce) { - nonce = event.context.nonce as string + if (rules.nonce) { + nonce = event.context.security.nonce // Scan all relevant sections of the NuxtRenderHtmlContext type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] diff --git a/src/runtime/nitro/plugins/99b-recombineHtml.ts b/src/runtime/nitro/plugins/99b-recombineHtml.ts index 32136ab3..90f3ee99 100644 --- a/src/runtime/nitro/plugins/99b-recombineHtml.ts +++ b/src/runtime/nitro/plugins/99b-recombineHtml.ts @@ -6,8 +6,8 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit if no need to parse HTML for this route - const { security } = getRouteRules(event) - if (!security?.sri && (!security?.headers || !security.headers.contentSecurityPolicy)) { + const { rules } = event.context.security + if (!rules?.sri && (!rules?.headers || !rules.headers.contentSecurityPolicy)) { return } diff --git a/src/runtime/server/middleware/cspNonceHandler.ts b/src/runtime/server/middleware/cspNonceHandler.ts index b5521d69..2a00068c 100644 --- a/src/runtime/server/middleware/cspNonceHandler.ts +++ b/src/runtime/server/middleware/cspNonceHandler.ts @@ -3,7 +3,7 @@ import { getRouteRules, defineEventHandler } from '#imports' export default defineEventHandler((event) => { const { security } = getRouteRules(event) - console.log('serverMiddleware', event.path, event) + console.log('serverMiddleware', event.path, event.context) if (security?.nonce) { const nonce = crypto.randomBytes(16).toString('base64') diff --git a/src/types/headers.ts b/src/types/headers.ts index 9e780251..2f4e84e8 100644 --- a/src/types/headers.ts +++ b/src/types/headers.ts @@ -1,3 +1,5 @@ +import type { NuxtSecurityRouteRules } from "."; + export type CrossOriginResourcePolicyValue = 'same-site' | 'same-origin' | 'cross-origin'; export type CrossOriginOpenerPolicyValue = 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin'; @@ -248,7 +250,8 @@ export interface SecurityHeaders { declare module 'h3' { interface H3EventContext { security: { - headers: SecurityHeaders + rules: Partial; + nonce?: string; } } } \ No newline at end of file From 251db64b3a3928156da7bc743001fcfb0ea44030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Sat, 13 Apr 2024 13:45:08 +0200 Subject: [PATCH 03/21] Minor improvements --- playground/nuxt.config.ts | 3 + playground/server/api/test.post.ts | 2 +- src/module.ts | 16 +- src/runtime/nitro/plugins/00-context.ts | 157 ++++++------------ src/runtime/nitro/plugins/01-hidePoweredBy.ts | 3 +- .../nitro/plugins/02-securityHeaders.ts | 65 +++++--- src/runtime/nitro/utils/index.ts | 4 +- src/runtime/utils/headers.ts | 16 ++ src/types/index.ts | 41 ++--- 9 files changed, 147 insertions(+), 160 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index bf49c50d..a953131b 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -17,6 +17,9 @@ export default defineNuxtConfig({ headers: { 'X-XSS-Protection': '1' } + }, + '/about': { + prerender: true } }, diff --git a/playground/server/api/test.post.ts b/playground/server/api/test.post.ts index 3dc929fa..272797da 100644 --- a/playground/server/api/test.post.ts +++ b/playground/server/api/test.post.ts @@ -1,3 +1,3 @@ export default defineEventHandler(async (event) => { - console.log('api test', event.path, event.context.security) + console.log('api test', event.path) }) diff --git a/src/module.ts b/src/module.ts index ce97be32..6e2d28f2 100644 --- a/src/module.ts +++ b/src/module.ts @@ -119,20 +119,14 @@ export default defineNuxtModule({ }) } + // Register nitro plugin to add security context to Nitro context + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-context')) - if(nuxt.options.security.runtimeHooks) { - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-context')) - } - - if (securityOptions.hidePoweredBy) { - nuxt.options.nitro.externals = nuxt.options.nitro.externals || {} - nuxt.options.nitro.externals.inline = nuxt.options.nitro.externals.inline || [] - nuxt.options.nitro.externals.inline.push(runtimeDir) - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/01-hidePoweredBy')) - } + // Register nitro plugin to hide X-Powered-By header + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/01-hidePoweredBy')) // Register nitro plugin to enable Security Headers - // addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02-securityHeaders')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02-securityHeaders')) // Pre-process HTML into DOM tree addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02a-preprocessHtml')) diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index 7608b655..fc1533b8 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -8,84 +8,13 @@ import type { H3Event } from "h3" import { Nuxt } from "@nuxt/schema" import defu from "defu" -function standardToSecurity(standardHeaders?: Record) { - const standardHeadersAsObject: SecurityHeaders = {} - - if (standardHeaders) { - Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { - const optionKey = getKeyFromName(headerName) - if (optionKey) { - if (typeof headerValue === 'string') { - // Normally, standard radix headers should be supplied as string - const objectValue: any = headerObjectFromString(optionKey, headerValue) - standardHeadersAsObject[optionKey] = objectValue - } else { - // Here we ensure backwards compatibility - // Because in the pre-rc1 syntax, standard headers could also be supplied in object format - standardHeadersAsObject[optionKey] = headerValue - standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) - } - } - }) - return standardHeadersAsObject - } else { - return undefined - } -} - -function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders) { - const securityHeadersAsObject: SecurityHeaders = {} - if (securityHeaders) { - Object.entries(securityHeaders).forEach(([key, value]) => { - const optionKey = key as OptionKey - if ((optionKey === 'contentSecurityPolicy' || optionKey === 'permissionsPolicy' || optionKey === 'strictTransportSecurity') && (typeof value === 'string')) { - // Altough this does not make sense in post-rc1 typescript definitions - // It was possible before rc1 though, so let's ensure backwards compatibility here - const objectValue: any = headerObjectFromString(optionKey, value) - securityHeadersAsObject[optionKey] = objectValue - } else if (value === '') { - securityHeadersAsObject[optionKey] = false - } else { - securityHeadersAsObject[optionKey] = value - } - }) - return securityHeadersAsObject - } else { - return undefined - } -} - -function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { - const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { - // Return boolean values unchanged - if (typeof value === 'boolean') { - return [directive, value] - } - // Make sure nonce placeholders are eliminated - const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value - const modifiedSources = sources - .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") - .map(source => { - if (source === "'nonce-{{nonce}}'") { - return nonce ? `'nonce-${nonce}'` : '' - } else { - return source - } - }) - .filter(source => source) - - return [directive, modifiedSources] - })) - return generatedCsp as ContentSecurityPolicyValue -} export default defineNitroPlugin((nitroApp) => { const config = useRuntimeConfig() const securityRouteRules: Record> = {} - // TO DO: CHECK POTENTIAL POLLUTION OF HEADERS OBJECT HERE // First insert standard route rules headers for (const route in config.nitro.routeRules) { @@ -98,7 +27,10 @@ export default defineNitroPlugin((nitroApp) => { // Then insert global security config const securityOptions = config.security - securityRouteRules['/**'] = securityOptions + securityRouteRules['/**'] = defuReplaceArray( + securityOptions, + securityRouteRules['/**'] + ) //securityRouteRules['/api/**'] = { headers: false } //securityRouteRules['/_nuxt/**'] = { headers: false } @@ -111,7 +43,7 @@ export default defineNitroPlugin((nitroApp) => { const { headers } = security if (headers) { const securityHeaders = backwardsCompatibleSecurity(headers) - securityRouteRules[route] = defu( + securityRouteRules[route] = defuReplaceArray( { headers: securityHeaders }, securityRouteRules[route], ) @@ -125,7 +57,7 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { - securityRouteRules[route] = defu( + securityRouteRules[route] = defuReplaceArray( { headers }, securityRouteRules[route] ) @@ -137,7 +69,7 @@ export default defineNitroPlugin((nitroApp) => { const matcher = toRouteMatcher(router) const matches = matcher.matchAll(event.path) - const rules: Partial = defu({}, ...matches.reverse()) + const rules: Partial = defuReplaceArray({}, ...matches.reverse()) event.context.security = { rules } @@ -145,37 +77,58 @@ export default defineNitroPlugin((nitroApp) => { const nonce = crypto.randomBytes(16).toString('base64') event.context.security.nonce = nonce } + }) + - console.log('request', event.path, event.context.security.nonce, event.context.security.rules) + nitroApp.hooks.callHook('nuxt-security:ready') +}) - }) -/* - nitroApp.hooks.hook('render:response', (response, { event }) => { - //console.log('render:response', event.path, event.context.security.counter, event.context.security.nonce, event.context.security.rules.headers) - }) -*/ - nitroApp.hooks.hook('render:response', (response, { event }) => { - - const nonce = event.context.security.nonce - const headers = { ...event.context.security.rules.headers } - console.log('render:response', event.path, nonce, headers) - - if (headers && headers.contentSecurityPolicy) { - const csp = headers.contentSecurityPolicy - headers.contentSecurityPolicy = insertNonceInCsp(csp, nonce) - } - if (headers) { - Object.entries(headers).forEach(([header, value]) => { - const headerName = getNameFromKey(header as OptionKey) - if (value === false) { - removeResponseHeader(event, headerName) +function standardToSecurity(standardHeaders?: Record) { + const standardHeadersAsObject: SecurityHeaders = {} + + if (standardHeaders) { + Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { + const optionKey = getKeyFromName(headerName) + if (optionKey) { + if (typeof headerValue === 'string') { + // Normally, standard radix headers should be supplied as string + const objectValue: any = headerObjectFromString(optionKey, headerValue) + standardHeadersAsObject[optionKey] = objectValue + } else { + // Here we ensure backwards compatibility + // Because in the pre-rc1 syntax, standard headers could also be supplied in object format + standardHeadersAsObject[optionKey] = headerValue + //standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) + } + } + }) + return standardHeadersAsObject + } else { + return undefined + } +} + +function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders) { + const securityHeadersAsObject: SecurityHeaders = {} + + if (securityHeaders) { + Object.entries(securityHeaders).forEach(([key, value]) => { + const optionKey = key as OptionKey + if ((optionKey === 'contentSecurityPolicy' || optionKey === 'permissionsPolicy' || optionKey === 'strictTransportSecurity') && (typeof value === 'string')) { + // Altough this does not make sense in post-rc1 typescript definitions + // It was possible before rc1 though, so let's ensure backwards compatibility here + const objectValue: any = headerObjectFromString(optionKey, value) + securityHeadersAsObject[optionKey] = objectValue + } else if (value === '') { + securityHeadersAsObject[optionKey] = false } else { - const headerValue = headerStringFromObject(header as OptionKey, value) - setResponseHeader(event, headerName, headerValue) + securityHeadersAsObject[optionKey] = value } }) + return securityHeadersAsObject + } else { + return undefined } - }) +} + - nitroApp.hooks.callHook('nuxt-security:ready') -}) diff --git a/src/runtime/nitro/plugins/01-hidePoweredBy.ts b/src/runtime/nitro/plugins/01-hidePoweredBy.ts index 13815f21..7bb7df3a 100644 --- a/src/runtime/nitro/plugins/01-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/01-hidePoweredBy.ts @@ -2,7 +2,8 @@ import { defineNitroPlugin, removeResponseHeader } from '#imports' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('beforeResponse', (event) => { - if (!event.node.res.headersSent) { + const { rules } = event.context.security + if (rules.hidePoweredBy && !event.node.res.headersSent) { removeResponseHeader(event, 'x-powered-by') } }) diff --git a/src/runtime/nitro/plugins/02-securityHeaders.ts b/src/runtime/nitro/plugins/02-securityHeaders.ts index 3585461e..325453af 100644 --- a/src/runtime/nitro/plugins/02-securityHeaders.ts +++ b/src/runtime/nitro/plugins/02-securityHeaders.ts @@ -1,28 +1,55 @@ import { getRouteRules, defineNitroPlugin, setResponseHeader, getResponseHeader, removeResponseHeader } from '#imports' -import { type OptionKey } from '../../../types/headers' +import { ContentSecurityPolicyValue, type OptionKey } from '../../../types/headers' import { getNameFromKey, headerStringFromObject } from '../../utils/headers' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', (_, {event}) => { - const { security } = getRouteRules(event) - if (security?.headers) { - const headers = event.context.security.headers - Object.entries(headers).forEach(([key, optionValue]) => { - const optionKey = key as OptionKey - const headerName = getNameFromKey(optionKey) - if (optionValue === false) { - const { headers: standardHeaders } = getRouteRules(event) - const standardHeaderValue = standardHeaders?.[headerName] - const currentHeaderValue = getResponseHeader(event, headerName) - if (standardHeaderValue === currentHeaderValue) { - removeResponseHeader(event, headerName) - } - } - else { - const headerValue = headerStringFromObject(optionKey, optionValue) + nitroApp.hooks.hook('render:response', (response, { event }) => { + const nonce = event.context.security.nonce + const headers = { ...event.context.security.rules.headers } + + if (headers && headers.contentSecurityPolicy) { + const csp = headers.contentSecurityPolicy + headers.contentSecurityPolicy = insertNonceInCsp(csp, nonce) + } + if (headers) { + Object.entries(headers).forEach(([header, value]) => { + const headerName = getNameFromKey(header as OptionKey) + if (value === false) { + removeResponseHeader(event, headerName) + } else { + const headerValue = headerStringFromObject(header as OptionKey, value) setResponseHeader(event, headerName, headerValue) } }) } + console.log('render:response', event.path, headers, response.headers) }) -}) \ No newline at end of file + + nitroApp.hooks.hook('beforeResponse', (event) => { + console.log('beforeResponse', event.path, event.context.security.rules.headers) + }) +}) + +function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { + const generatedCsp = Object.fromEntries(Object.entries(csp).map(([directive, value]) => { + // Return boolean values unchanged + if (typeof value === 'boolean') { + return [directive, value] + } + // Make sure nonce placeholders are eliminated + const sources = (typeof value === 'string') ? value.split(' ').map(token => token.trim()).filter(token => token) : value + const modifiedSources = sources + .filter(source => !source.startsWith("'nonce-") || source === "'nonce-{{nonce}}'") + .map(source => { + if (source === "'nonce-{{nonce}}'") { + return nonce ? `'nonce-${nonce}'` : '' + } else { + return source + } + }) + .filter(source => source) + + return [directive, modifiedSources] + })) + return generatedCsp as ContentSecurityPolicyValue +} \ No newline at end of file diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index ec1a338b..90d8862b 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -6,5 +6,7 @@ import { getRequestHeader } from '#imports' * @returns boolean */ export function isPrerendering(event: H3Event): boolean { - return !!getRequestHeader(event, 'x-nitro-prerender') + const isPrerendering = !!getRequestHeader(event, 'x-nitro-prerender') + console.log('isPrerendering', isPrerendering, import.meta.prerender) + return isPrerendering } \ No newline at end of file diff --git a/src/runtime/utils/headers.ts b/src/runtime/utils/headers.ts index b65c6d97..34c4ccb4 100644 --- a/src/runtime/utils/headers.ts +++ b/src/runtime/utils/headers.ts @@ -26,15 +26,27 @@ export const KEYS_TO_NAMES: Record = { const NAMES_TO_KEYS = Object.fromEntries(Object.entries(KEYS_TO_NAMES).map(([key, name]) => ([name, key]))) as Record +/** + * + * Converts a valid OptionKey into its corresponding standard header name + */ export function getNameFromKey(key: OptionKey) { return KEYS_TO_NAMES[key] } +/** + * + * Converts a standard header name to its corresponding OptionKey name, or undefined if not found + */ export function getKeyFromName(headerName: string) { const [, key] = Object.entries(NAMES_TO_KEYS).find(([name]) => name.toLowerCase() === headerName.toLowerCase()) || [] return key } +/** + * + * Gigen a valid OptionKey, converts a header object value into its corresponding string format + */ export function headerStringFromObject(optionKey: OptionKey, optionValue: Exclude) { // False value translates into empty header if (optionValue === false) { @@ -86,6 +98,10 @@ export function headerStringFromObject(optionKey: OptionKey, optionValue: Exclud } } +/** + * + * Given a valid OptionKey, converts a header value string into its corresponding object format + */ export function headerObjectFromString(optionKey: OptionKey, headerValue: string) { // Empty string should remove header if (!headerValue) { diff --git a/src/types/index.ts b/src/types/index.ts index eb2405f6..b14af497 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,29 +31,20 @@ export interface ModuleOptions { sri: boolean } -export type NuxtSecurityRouteRules = Pick +export type NuxtSecurityRouteRules = Partial - declare module 'nitropack' { - interface NitroRuntimeHooks { - 'nuxt-security:headers': (config: { - /** - * The route for which the headers are being configured - */ - route: string, - /** - * The headers configuration for the route - */ - headers: SecurityHeaders - }) => void - 'nuxt-security:ready': () => void - } - } \ No newline at end of file +declare module 'nitropack' { + interface NitroRuntimeHooks { + 'nuxt-security:headers': (config: { + /** + * The route for which the headers are being configured + */ + route: string, + /** + * The headers configuration for the route + */ + headers: SecurityHeaders + }) => void + 'nuxt-security:ready': () => void + } +} \ No newline at end of file From 7baf438d970ab2abdc2cd53fed9b7e06c84211ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Mon, 15 Apr 2024 23:11:25 +0200 Subject: [PATCH 04/21] More work --- src/module.ts | 158 +++--------------- src/runtime/nitro/plugins/00-context.ts | 27 ++- .../nitro/plugins/02-securityHeaders.ts | 4 - src/runtime/nitro/utils/index.ts | 2 +- .../middleware/allowedMethodsRestricter.ts | 10 +- src/runtime/server/middleware/corsHandler.ts | 6 +- .../server/middleware/cspNonceHandler.ts | 12 -- src/runtime/server/middleware/rateLimiter.ts | 8 +- .../server/middleware/requestSizeLimiter.ts | 10 +- src/runtime/server/middleware/xssValidator.ts | 16 +- test/fixtures/runtime-hooks/nuxt.config.ts | 4 +- .../runtime-hooks/server/api/runtime-hooks.ts | 2 +- .../runtime-hooks/server/plugins/headers.ts | 32 ++-- test/runtime-hooks.test.ts | 18 +- 14 files changed, 93 insertions(+), 216 deletions(-) delete mode 100644 src/runtime/server/middleware/cspNonceHandler.ts diff --git a/src/module.ts b/src/module.ts index 6e2d28f2..55ea7215 100644 --- a/src/module.ts +++ b/src/module.ts @@ -47,7 +47,7 @@ export default defineNuxtModule({ }, async setup (options, nuxt) { const resolver = createResolver(import.meta.url) - const runtimeDir = resolver.resolve('./runtime') + nuxt.options.build.transpile.push(resolver.resolve('./runtime')) nuxt.options.security = defuReplaceArray( { ...options, ...nuxt.options.security }, @@ -59,6 +59,8 @@ export default defineNuxtModule({ // Disabled module when `enabled` is set to `false` if (!securityOptions.enabled) { return } + registerStorageDriver(nuxt, securityOptions) + if (securityOptions.removeLoggers) { addVitePlugin(viteRemove(securityOptions.removeLoggers)) } @@ -78,46 +80,6 @@ export default defineNuxtModule({ ...securityOptions } ) - - - // PER ROUTE OPTIONS - //setGlobalSecurityRoute(nuxt, securityOptions) - //mergeSecurityPerRoute(nuxt) - - -/* - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/cspNonceHandler') - }) - - */ - - if (nuxt.options.security.requestSizeLimiter) { - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/requestSizeLimiter') - }) - } - - if (nuxt.options.security.rateLimiter) { - registerStorageDriver(nuxt, securityOptions) - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/rateLimiter') - }) - } - - if (nuxt.options.security.xssValidator) { - // Remove potential duplicates - nuxt.options.security.xssValidator.methods = Array.from(new Set(nuxt.options.security.xssValidator.methods)) - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/xssValidator') - }) - } - - if (nuxt.options.security.corsHandler) { - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/corsHandler') - }) - } // Register nitro plugin to add security context to Nitro context addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-context')) @@ -146,22 +108,29 @@ export default defineNuxtModule({ // Nitro plugin to enable CSP Nonce for SSR addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99-cspSsrNonce')) - // Recombine HTML from DOM tree addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99b-recombineHtml')) + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/requestSizeLimiter') + }) - const allowedMethodsRestricterConfig = nuxt.options.security - .allowedMethodsRestricter - if ( - allowedMethodsRestricterConfig && - !Object.values(allowedMethodsRestricterConfig).includes('*') - ) { - addServerHandler({ - handler: resolver.resolve('./runtime/server/middleware/allowedMethodsRestricter') - }) - } + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/corsHandler') + }) + + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/allowedMethodsRestricter') + }) + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/rateLimiter') + }) + + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/xssValidator') + }) + // Register basicAuth middleware that is disabled by default const basicAuthConfig = nuxt.options.runtimeConfig.private .basicAuth as unknown as BasicAuth @@ -191,91 +160,6 @@ export default defineNuxtModule({ } }) -// Adds the global security options to all routes -function setGlobalSecurityRoute(nuxt: Nuxt, securityOptions: ModuleOptions) { - nuxt.options.nitro.routeRules = defuReplaceArray( - { - '/**': { security: securityOptions } - }, - nuxt.options.nitro.routeRules, - /*{ - '/api/**' : { security: { headers: false } as { headers: false } }, - '/_nuxt/**' : { security : { headers: false } as { headers: false } } - },*/ - ) -} - -// Merges the standard headers into the security options -function mergeSecurityPerRoute(nuxt: Nuxt) { - for (const route in nuxt.options.nitro.routeRules) { - const rule = nuxt.options.nitro.routeRules[route] - const { security, headers: standardHeaders } = rule - - // STEP 1 - DETECT STANDARD HEADERS THAT MAY OVERLAP WITH SECURITY HEADERS - // Lookup standard radix headers - // Detect if they belong to one of the SecurityHeaders - // And convert them into object format - const standardHeadersAsObject: SecurityHeaders = {} - - if (standardHeaders) { - Object.entries(standardHeaders).forEach(([headerName, headerValue]) => { - const optionKey = getKeyFromName(headerName) - if (optionKey) { - if (typeof headerValue === 'string') { - // Normally, standard radix headers should be supplied as string - const objectValue: any = headerObjectFromString(optionKey, headerValue) - standardHeadersAsObject[optionKey] = objectValue - } else { - // Here we ensure backwards compatibility - // Because in the pre-rc1 syntax, standard headers could also be supplied in object format - standardHeadersAsObject[optionKey] = headerValue - standardHeaders[headerName] = headerStringFromObject(optionKey, headerValue) - } - } - }) - } - - // STEP 2 - ENSURE BACKWARDS COMPATIBILITY OF SECURITY HEADERS - // Lookup the Security headers, normally they should be in object format - // However detect if they were supplied in string format - // And convert them into object format - const securityHeadersAsObject: SecurityHeaders = {} - - if (security?.headers) { - const { headers: securityHeaders } = security - Object.entries(securityHeaders).forEach(([key, value]) => { - const optionKey = key as OptionKey - if ((optionKey === 'contentSecurityPolicy' || optionKey === 'permissionsPolicy' || optionKey === 'strictTransportSecurity') && (typeof value === 'string')) { - // Altough this does not make sense in post-rc1 typescript definitions - // It was possible before rc1 though, so let's ensure backwards compatibility here - const objectValue: any = headerObjectFromString(optionKey, value) - securityHeadersAsObject[optionKey] = objectValue - } else if (value === '') { - securityHeadersAsObject[optionKey] = false - } else { - securityHeadersAsObject[optionKey] = value - } - }) - } - - // STEP 3 - MERGE RESULT INTO SECURITY RULE - // Security headers have priority - const mergedHeadersAsObject = defuReplaceArray( - securityHeadersAsObject, - standardHeadersAsObject - ) - if (Object.keys(mergedHeadersAsObject).length) { - nuxt.options.nitro.routeRules[route] = defuReplaceArray( - { security: { - headers: mergedHeadersAsObject - } - }, - rule - ) - } - } -} - function registerStorageDriver(nuxt: Nuxt, securityOptions: ModuleOptions) { nuxt.hook('nitro:config', (config) => { diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index fc1533b8..a2e02120 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -31,8 +31,16 @@ export default defineNitroPlugin((nitroApp) => { securityOptions, securityRouteRules['/**'] ) - //securityRouteRules['/api/**'] = { headers: false } - //securityRouteRules['/_nuxt/**'] = { headers: false } + /* + securityRouteRules['/api/**'] = defuReplaceArray( + { headers: false }, + securityRouteRules['/api/**'] + ) + securityRouteRules['/_nuxt/**'] = defuReplaceArray( + { headers: false }, + securityRouteRules['/_nuxt/**'] + ) + */ // Then insert route specific security headers @@ -41,13 +49,15 @@ export default defineNitroPlugin((nitroApp) => { const { security } = rule if (security) { const { headers } = security + let securityHeaders if (headers) { - const securityHeaders = backwardsCompatibleSecurity(headers) - securityRouteRules[route] = defuReplaceArray( - { headers: securityHeaders }, - securityRouteRules[route], - ) + securityHeaders = backwardsCompatibleSecurity(headers) } + securityRouteRules[route] = defuReplaceArray( + { headers: securityHeaders }, + security, + securityRouteRules[route], + ) } } @@ -66,9 +76,8 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('request', (event) => { - const matcher = toRouteMatcher(router) - const matches = matcher.matchAll(event.path) + const matches = matcher.matchAll(event.path.split('?')[0]) const rules: Partial = defuReplaceArray({}, ...matches.reverse()) event.context.security = { rules } diff --git a/src/runtime/nitro/plugins/02-securityHeaders.ts b/src/runtime/nitro/plugins/02-securityHeaders.ts index 325453af..e9206d8c 100644 --- a/src/runtime/nitro/plugins/02-securityHeaders.ts +++ b/src/runtime/nitro/plugins/02-securityHeaders.ts @@ -22,12 +22,8 @@ export default defineNitroPlugin((nitroApp) => { } }) } - console.log('render:response', event.path, headers, response.headers) }) - nitroApp.hooks.hook('beforeResponse', (event) => { - console.log('beforeResponse', event.path, event.context.security.rules.headers) - }) }) function insertNonceInCsp(csp: ContentSecurityPolicyValue, nonce?: string) { diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index 90d8862b..60ee4937 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -7,6 +7,6 @@ import { getRequestHeader } from '#imports' */ export function isPrerendering(event: H3Event): boolean { const isPrerendering = !!getRequestHeader(event, 'x-nitro-prerender') - console.log('isPrerendering', isPrerendering, import.meta.prerender) + // console.log('isPrerendering', isPrerendering, import.meta.prerender) return isPrerendering } \ No newline at end of file diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index 5853bf60..32d15f61 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -1,13 +1,15 @@ import { getRouteRules, defineEventHandler, createError } from '#imports' +import { HTTPMethod } from '~/src/module' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const { rules } = event.context.security - if (security?.allowedMethodsRestricter) { - const { allowedMethodsRestricter } = security + if (rules?.allowedMethodsRestricter) { + const { allowedMethodsRestricter } = rules const allowedMethods = allowedMethodsRestricter.methods - if (allowedMethods !== '*' && !allowedMethods.includes(event.node.req.method!)) { + + if (allowedMethods !== '*' && !allowedMethods.includes(event.node.req.method! as HTTPMethod)) { const methodNotAllowedError = { statusCode: 405, statusMessage: 'Method not allowed' diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts index 85573b88..46e32574 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -2,10 +2,10 @@ import { getRouteRules, defineEventHandler, handleCors } from '#imports' import type { H3CorsOptions } from 'h3' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const { rules } = event.context.security - if (security?.corsHandler) { - const { corsHandler } = security + if (rules?.corsHandler) { + const { corsHandler } = rules handleCors(event, corsHandler as H3CorsOptions) } diff --git a/src/runtime/server/middleware/cspNonceHandler.ts b/src/runtime/server/middleware/cspNonceHandler.ts deleted file mode 100644 index 2a00068c..00000000 --- a/src/runtime/server/middleware/cspNonceHandler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import crypto from 'node:crypto' -import { getRouteRules, defineEventHandler } from '#imports' - -export default defineEventHandler((event) => { - const { security } = getRouteRules(event) - console.log('serverMiddleware', event.path, event.context) - - if (security?.nonce) { - const nonce = crypto.randomBytes(16).toString('base64') - //event.context.nonce = nonce - } -}) diff --git a/src/runtime/server/middleware/rateLimiter.ts b/src/runtime/server/middleware/rateLimiter.ts index 81f80874..a1541276 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -10,10 +10,10 @@ type StorageItem = { const storage = useStorage('#storage-driver') export default defineEventHandler(async (event) => { - const { security } = getRouteRules(event) + const { rules } = event.context.security - if (security?.rateLimiter) { - const { rateLimiter } = security + if (rules?.rateLimiter) { + const { rateLimiter } = rules const ip = getIP(event) let storageItem = await storage.getItem(ip) as StorageItem @@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => { statusMessage: 'Too Many Requests' } - if (security.rateLimiter.headers) { + if (rules.rateLimiter.headers) { setResponseHeader(event, 'x-ratelimit-remaining', 0) setResponseHeader(event, 'x-ratelimit-limit', rateLimiter.tokensPerInterval) setResponseHeader(event, 'x-ratelimit-reset', timeForInterval) diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index 6144f5b9..cb32076f 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -3,9 +3,9 @@ import { defineEventHandler, getRequestHeader, createError, getRouteRules } from const FILE_UPLOAD_HEADER = 'multipart/form-data' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const { rules } = event.context.security - if (security?.requestSizeLimiter) { + if (rules?.requestSizeLimiter) { if (['POST', 'PUT', 'DELETE'].includes(event.node.req.method!)) { const contentLengthValue = getRequestHeader(event, 'content-length') const contentTypeValue = getRequestHeader(event, 'content-type') @@ -13,15 +13,15 @@ export default defineEventHandler((event) => { const isFileUpload = contentTypeValue?.includes(FILE_UPLOAD_HEADER) const requestLimit = isFileUpload - ? security.requestSizeLimiter.maxUploadFileRequestInBytes - : security.requestSizeLimiter.maxRequestSizeInBytes + ? rules.requestSizeLimiter.maxUploadFileRequestInBytes + : rules.requestSizeLimiter.maxRequestSizeInBytes if (parseInt(contentLengthValue as string) >= requestLimit) { const payloadTooLargeError = { statusCode: 413, statusMessage: 'Payload Too Large' } - if (security.requestSizeLimiter.throwError === false) { + if (rules.requestSizeLimiter.throwError === false) { return payloadTooLargeError } throw createError(payloadTooLargeError) diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index 85e9e252..1e991e05 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -4,19 +4,19 @@ import { createError, getQuery, readBody, + readMultipartFormData, getRouteRules } from '#imports' import { HTTPMethod } from '~/src/module' export default defineEventHandler(async (event) => { - const { security } = getRouteRules(event) - - if (security?.xssValidator) { + const { rules } = event.context.security + if (rules?.xssValidator) { const filterOpt: IFilterXSSOptions = { - ...security.xssValidator, + ...rules.xssValidator, escapeHtml: undefined } - if (security.xssValidator.escapeHtml === false) { + if (rules.xssValidator.escapeHtml === false) { // No html escaping (by default "<" is replaced by "<" and ">" by ">") filterOpt.escapeHtml = (value: string) => value } @@ -24,8 +24,8 @@ export default defineEventHandler(async (event) => { if (event.node.req.socket.readyState !== 'readOnly') { if ( - security.xssValidator.methods && - security.xssValidator.methods.includes( + rules.xssValidator.methods && + rules.xssValidator.methods.includes( event.node.req.method! as HTTPMethod ) ) { @@ -54,7 +54,7 @@ export default defineEventHandler(async (event) => { statusCode: 400, statusMessage: 'Bad Request' } - if (security.xssValidator.throwError === false) { + if (rules.xssValidator.throwError === false) { return badRequestError } diff --git a/test/fixtures/runtime-hooks/nuxt.config.ts b/test/fixtures/runtime-hooks/nuxt.config.ts index b01b97cf..c164132a 100644 --- a/test/fixtures/runtime-hooks/nuxt.config.ts +++ b/test/fixtures/runtime-hooks/nuxt.config.ts @@ -1,8 +1,6 @@ -import MyModule from '../../../src/module' - export default defineNuxtConfig({ modules: [ - MyModule + '../../../src/module' ], routeRules:{ '/test': { diff --git a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts b/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts index 8d9ea963..9b6dc3dc 100644 --- a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts +++ b/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts @@ -1,5 +1,5 @@ -import { getResponseHeader } from "h3" export default defineEventHandler((event) => { + console.log('server api', event.path, event.context.security.rules.headers?.contentSecurityPolicy) return "runtime-hooks" }) \ No newline at end of file diff --git a/test/fixtures/runtime-hooks/server/plugins/headers.ts b/test/fixtures/runtime-hooks/server/plugins/headers.ts index db5826e1..ec2906e8 100644 --- a/test/fixtures/runtime-hooks/server/plugins/headers.ts +++ b/test/fixtures/runtime-hooks/server/plugins/headers.ts @@ -1,19 +1,19 @@ export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('nuxt-security:ready', () => { - nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/api/runtime-hooks', headers: { - contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", '*.azure.com'], - } - } - }) - nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/', - headers: { - contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", "some-value.com"], - } - } - }) + nitroApp.hooks.hook('nuxt-security:ready', () => { + nitroApp.hooks.callHook('nuxt-security:headers', { + route: '/api/runtime-hooks', headers: { + contentSecurityPolicy: { + "script-src": ["'self'", "'unsafe-inline'", '*.azure.com'], + } + } }) + nitroApp.hooks.callHook('nuxt-security:headers', { + route: '/', + headers: { + contentSecurityPolicy: { + "script-src": ["'self'", "'unsafe-inline'", "some-value.com"], + } + } + }) + }) }) \ No newline at end of file diff --git a/test/runtime-hooks.test.ts b/test/runtime-hooks.test.ts index 7fc3334e..9c880cc2 100644 --- a/test/runtime-hooks.test.ts +++ b/test/runtime-hooks.test.ts @@ -3,17 +3,17 @@ import { describe, it, expect } from 'vitest' import { setup, fetch } from '@nuxt/test-utils' await setup({ - rootDir: fileURLToPath(new URL('./fixtures/runtime-hooks', import.meta.url)) + rootDir: fileURLToPath(new URL('./fixtures/runtime-hooks', import.meta.url)) }) describe('[nuxt-security] runtime hooks', () => { - it('expect csp to be set by a runtime hook', async () => { - const res = await fetch('/api/runtime-hooks') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' *.azure.com;"') - }) + it('expect csp to be set by a runtime hook', async () => { + const res = await fetch('/api/runtime-hooks') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' *.azure.com;"') + }) - it('expect runtime hooks to override configuration in an html response #369', async () => { - const res = await fetch('/') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' some-value.com;"') - }) + it('expect runtime hooks to override configuration in an html response #369', async () => { + const res = await fetch('/') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' some-value.com;"') + }) }) \ No newline at end of file From 437b7e56ae93c954adf12565722bff9805d0ca39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 16 Apr 2024 17:37:10 +0200 Subject: [PATCH 05/21] working hook --- playground/pages/runtime.vue | 6 ++++++ src/runtime/nitro/plugins/00-context.ts | 1 + 2 files changed, 7 insertions(+) create mode 100644 playground/pages/runtime.vue diff --git a/playground/pages/runtime.vue b/playground/pages/runtime.vue new file mode 100644 index 00000000..5d26a599 --- /dev/null +++ b/playground/pages/runtime.vue @@ -0,0 +1,6 @@ + + diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index a2e02120..e999f555 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -67,6 +67,7 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { + console.log('hooking', route, headers) securityRouteRules[route] = defuReplaceArray( { headers }, securityRouteRules[route] From 756674463d7d38e64888a7e28f98991dd6e71e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 16 Apr 2024 18:36:56 +0200 Subject: [PATCH 06/21] fix tests for runtime hooks - only HTML renderer writes security headers - runtime hook is able to fetch dynamic values from API --- playground/server/api/runtime-hooks.ts | 8 ++++++-- playground/server/plugins/headers.ts | 1 - src/defaultConfig.ts | 1 + src/module.ts | 2 ++ src/runtime/nitro/plugins/00-context.ts | 1 - test/fixtures/perRoute/server/middleware/preserve-test.ts | 4 ++-- test/fixtures/runtime-hooks/server/api/runtime-hooks.ts | 8 +++++--- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/playground/server/api/runtime-hooks.ts b/playground/server/api/runtime-hooks.ts index e7d03e25..1cb91e11 100644 --- a/playground/server/api/runtime-hooks.ts +++ b/playground/server/api/runtime-hooks.ts @@ -1,7 +1,11 @@ import { defineEventHandler } from "#imports" export default defineEventHandler((event) => { - return { - csp: getResponseHeader(event, 'Content-Security-Policy') + return { + headers: { + contentSecurityPolicy: { + 'script-src': ['self', 'toto'], + } } + } }) \ No newline at end of file diff --git a/playground/server/plugins/headers.ts b/playground/server/plugins/headers.ts index a2cc10d4..2a0e9a37 100644 --- a/playground/server/plugins/headers.ts +++ b/playground/server/plugins/headers.ts @@ -1,4 +1,3 @@ - export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:ready', () => { nitroApp.hooks.callHook('nuxt-security:headers', diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index b1105371..11294494 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -8,6 +8,7 @@ export const defaultSecurityConfig = (serverlUrl: string): Partial { const resolver = createResolver(import.meta.url) const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') @@ -222,4 +223,5 @@ function registerStorageDriver(nuxt: Nuxt, securityOptions: ModuleOptions) { }) }) }) + */ } diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index e999f555..84fd8489 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -89,7 +89,6 @@ export default defineNitroPlugin((nitroApp) => { } }) - nitroApp.hooks.callHook('nuxt-security:ready') }) diff --git a/test/fixtures/perRoute/server/middleware/preserve-test.ts b/test/fixtures/perRoute/server/middleware/preserve-test.ts index de2e2ace..ff32b2d1 100644 --- a/test/fixtures/perRoute/server/middleware/preserve-test.ts +++ b/test/fixtures/perRoute/server/middleware/preserve-test.ts @@ -1,6 +1,6 @@ export default defineEventHandler((event) => { if (event.path.startsWith('/preserve-middleware')) { - appendHeader(event, 'Content-Security-Policy', 'example') - setHeader(event, 'Referrer-Policy', 'harder-example') + setResponseHeader(event, 'Content-Security-Policy', 'example') + setResponseHeader(event, 'Referrer-Policy', 'harder-example') } }) diff --git a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts b/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts index 9b6dc3dc..f92f83aa 100644 --- a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts +++ b/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts @@ -1,5 +1,7 @@ - + export default defineEventHandler((event) => { - console.log('server api', event.path, event.context.security.rules.headers?.contentSecurityPolicy) - return "runtime-hooks" + const { headers } = event.context.security.rules + return { + csp: headers ? headers.contentSecurityPolicy : undefined + } }) \ No newline at end of file From e0bb67eb418a74b7e92f8ca32176cec0442674e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 16 Apr 2024 18:36:58 +0200 Subject: [PATCH 07/21] Commit fix --- playground/server/api/runtime-headers.ts | 14 +++++++++++++ playground/server/api/runtime-hooks.ts | 11 ---------- playground/server/plugins/headers.ts | 19 +++++++---------- src/module.ts | 6 ++++-- src/runtime/nitro/plugins/00-context.ts | 1 - test/fixtures/runtime-hooks/pages/index.vue | 3 --- .../runtime-hooks/server/api/runtime-hooks.ts | 7 ------- .../runtime-hooks/server/plugins/headers.ts | 19 ----------------- .../{runtime-hooks => runtimeHooks}/.nuxtrc | 0 .../{runtime-hooks => runtimeHooks}/app.vue | 0 .../nuxt.config.ts | 0 .../package.json | 0 test/fixtures/runtimeHooks/pages/dynamic.vue | 3 +++ test/fixtures/runtimeHooks/pages/static.vue | 3 +++ .../server/api/runtime-headers.ts | 9 ++++++++ .../runtimeHooks/server/plugins/headers.ts | 21 +++++++++++++++++++ test/runtime-hooks.test.ts | 19 ----------------- test/runtimeHooks.test.ts | 19 +++++++++++++++++ 18 files changed, 81 insertions(+), 73 deletions(-) create mode 100644 playground/server/api/runtime-headers.ts delete mode 100644 playground/server/api/runtime-hooks.ts delete mode 100644 test/fixtures/runtime-hooks/pages/index.vue delete mode 100644 test/fixtures/runtime-hooks/server/api/runtime-hooks.ts delete mode 100644 test/fixtures/runtime-hooks/server/plugins/headers.ts rename test/fixtures/{runtime-hooks => runtimeHooks}/.nuxtrc (100%) rename test/fixtures/{runtime-hooks => runtimeHooks}/app.vue (100%) rename test/fixtures/{runtime-hooks => runtimeHooks}/nuxt.config.ts (100%) rename test/fixtures/{runtime-hooks => runtimeHooks}/package.json (100%) create mode 100644 test/fixtures/runtimeHooks/pages/dynamic.vue create mode 100644 test/fixtures/runtimeHooks/pages/static.vue create mode 100644 test/fixtures/runtimeHooks/server/api/runtime-headers.ts create mode 100644 test/fixtures/runtimeHooks/server/plugins/headers.ts delete mode 100644 test/runtime-hooks.test.ts create mode 100644 test/runtimeHooks.test.ts diff --git a/playground/server/api/runtime-headers.ts b/playground/server/api/runtime-headers.ts new file mode 100644 index 00000000..800ea14a --- /dev/null +++ b/playground/server/api/runtime-headers.ts @@ -0,0 +1,14 @@ +import { defineEventHandler } from "#imports" + +export default defineEventHandler((event) => { + const time = new Date().toISOString() + return { + headers: { + contentSecurityPolicy: { + 'script-src': ["'self'", "'unsafe-inline'", "'nonce-{{nonce}}'", time] + // Time is not a valid CSP value, but it's just an example to verify that it's set once and not re-evaluated + // Nonce is provided in valid placeholder format, and it's set to be replaced with the proper nonce value + }, + } + } +}) \ No newline at end of file diff --git a/playground/server/api/runtime-hooks.ts b/playground/server/api/runtime-hooks.ts deleted file mode 100644 index 1cb91e11..00000000 --- a/playground/server/api/runtime-hooks.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineEventHandler } from "#imports" - -export default defineEventHandler((event) => { - return { - headers: { - contentSecurityPolicy: { - 'script-src': ['self', 'toto'], - } - } - } -}) \ No newline at end of file diff --git a/playground/server/plugins/headers.ts b/playground/server/plugins/headers.ts index 2a0e9a37..017404a6 100644 --- a/playground/server/plugins/headers.ts +++ b/playground/server/plugins/headers.ts @@ -1,13 +1,10 @@ export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('nuxt-security:ready', () => { - nitroApp.hooks.callHook('nuxt-security:headers', - { - route: '/api/runtime-hooks', - headers: { - contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'"], - } - } - }) - }) + nitroApp.hooks.hook('nuxt-security:ready', async() => { + const { headers } = await $fetch('/api/runtime-headers') + nitroApp.hooks.callHook('nuxt-security:headers', + { + route: '/runtime', + headers + }) + }) }) \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index 872efef3..32b18bc1 100644 --- a/src/module.ts +++ b/src/module.ts @@ -183,8 +183,10 @@ function registerStorageDriver(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 - /* + nuxt.hook('nitro:init', nitro => { + + const resolver = createResolver(import.meta.url) const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') @@ -222,6 +224,6 @@ function registerStorageDriver(nuxt: Nuxt, securityOptions: ModuleOptions) { } }) }) + }) - */ } diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts index 84fd8489..9604747c 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-context.ts @@ -67,7 +67,6 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { - console.log('hooking', route, headers) securityRouteRules[route] = defuReplaceArray( { headers }, securityRouteRules[route] diff --git a/test/fixtures/runtime-hooks/pages/index.vue b/test/fixtures/runtime-hooks/pages/index.vue deleted file mode 100644 index 138b204f..00000000 --- a/test/fixtures/runtime-hooks/pages/index.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts b/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts deleted file mode 100644 index f92f83aa..00000000 --- a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ - -export default defineEventHandler((event) => { - const { headers } = event.context.security.rules - return { - csp: headers ? headers.contentSecurityPolicy : undefined - } -}) \ No newline at end of file diff --git a/test/fixtures/runtime-hooks/server/plugins/headers.ts b/test/fixtures/runtime-hooks/server/plugins/headers.ts deleted file mode 100644 index ec2906e8..00000000 --- a/test/fixtures/runtime-hooks/server/plugins/headers.ts +++ /dev/null @@ -1,19 +0,0 @@ -export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('nuxt-security:ready', () => { - nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/api/runtime-hooks', headers: { - contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", '*.azure.com'], - } - } - }) - nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/', - headers: { - contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", "some-value.com"], - } - } - }) - }) -}) \ No newline at end of file diff --git a/test/fixtures/runtime-hooks/.nuxtrc b/test/fixtures/runtimeHooks/.nuxtrc similarity index 100% rename from test/fixtures/runtime-hooks/.nuxtrc rename to test/fixtures/runtimeHooks/.nuxtrc diff --git a/test/fixtures/runtime-hooks/app.vue b/test/fixtures/runtimeHooks/app.vue similarity index 100% rename from test/fixtures/runtime-hooks/app.vue rename to test/fixtures/runtimeHooks/app.vue diff --git a/test/fixtures/runtime-hooks/nuxt.config.ts b/test/fixtures/runtimeHooks/nuxt.config.ts similarity index 100% rename from test/fixtures/runtime-hooks/nuxt.config.ts rename to test/fixtures/runtimeHooks/nuxt.config.ts diff --git a/test/fixtures/runtime-hooks/package.json b/test/fixtures/runtimeHooks/package.json similarity index 100% rename from test/fixtures/runtime-hooks/package.json rename to test/fixtures/runtimeHooks/package.json diff --git a/test/fixtures/runtimeHooks/pages/dynamic.vue b/test/fixtures/runtimeHooks/pages/dynamic.vue new file mode 100644 index 00000000..b72c6da7 --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/dynamic.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/runtimeHooks/pages/static.vue b/test/fixtures/runtimeHooks/pages/static.vue new file mode 100644 index 00000000..cf247eaa --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/static.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/runtimeHooks/server/api/runtime-headers.ts b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts new file mode 100644 index 00000000..2b2909e1 --- /dev/null +++ b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts @@ -0,0 +1,9 @@ + +export default defineEventHandler((event) => { + const headers = { + contentSecurityPolicy: { + "script-src": ["'self'", "'unsafe-inline'", '*.dynamic-value.com'], + } + } + return { headers } +}) \ No newline at end of file diff --git a/test/fixtures/runtimeHooks/server/plugins/headers.ts b/test/fixtures/runtimeHooks/server/plugins/headers.ts new file mode 100644 index 00000000..5894e89b --- /dev/null +++ b/test/fixtures/runtimeHooks/server/plugins/headers.ts @@ -0,0 +1,21 @@ +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('nuxt-security:ready', async() => { + + // CSP will be set to the static values provided here + nitroApp.hooks.callHook('nuxt-security:headers', { + route: '/static', + headers: { + contentSecurityPolicy: { + "script-src": ["'self'", "'unsafe-inline'", "static-value.com"], + } + } + }) + + // CSP will be set to the dynamic values fetched from the API + const { headers } = await $fetch('/api/runtime-headers') + nitroApp.hooks.callHook('nuxt-security:headers', { + route: '/dynamic', + headers + }) + }) +}) \ No newline at end of file diff --git a/test/runtime-hooks.test.ts b/test/runtime-hooks.test.ts deleted file mode 100644 index 9c880cc2..00000000 --- a/test/runtime-hooks.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, it, expect } from 'vitest' -import { setup, fetch } from '@nuxt/test-utils' - -await setup({ - rootDir: fileURLToPath(new URL('./fixtures/runtime-hooks', import.meta.url)) -}) - -describe('[nuxt-security] runtime hooks', () => { - it('expect csp to be set by a runtime hook', async () => { - const res = await fetch('/api/runtime-hooks') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' *.azure.com;"') - }) - - it('expect runtime hooks to override configuration in an html response #369', async () => { - const res = await fetch('/') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot('"script-src \'self\' \'unsafe-inline\' some-value.com;"') - }) -}) \ No newline at end of file diff --git a/test/runtimeHooks.test.ts b/test/runtimeHooks.test.ts new file mode 100644 index 00000000..557d606d --- /dev/null +++ b/test/runtimeHooks.test.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, fetch } from '@nuxt/test-utils' + +await setup({ + rootDir: fileURLToPath(new URL('./fixtures/runtimeHooks', import.meta.url)) +}) + +describe('[nuxt-security] runtime hooks', () => { + it('expect csp to be set to static values by a runtime hook', async () => { + const res = await fetch('/static') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' 'unsafe-inline' static-value.com; upgrade-insecure-requests;\"") + }) + + it('expect csp to be set to dynamically-fetched values by a runtime hook', async () => { + const res = await fetch('/dynamic') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.dynamic-value.com; upgrade-insecure-requests;\"") + }) +}) \ No newline at end of file From 3c22c2e50d3d7fdbdf423ba91912c59b877dabe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Wed, 17 Apr 2024 14:12:25 +0200 Subject: [PATCH 08/21] introduce resolveSecurityRules composable --- src/module.ts | 29 ++++++----- src/runtime/composables/context.ts | 15 ++++++ src/runtime/composables/nonce.ts | 2 +- .../plugins/{00-context.ts => 00-router.ts} | 52 +++---------------- src/runtime/nitro/plugins/10-nonce.ts | 18 +++++++ ...1-hidePoweredBy.ts => 20-hidePoweredBy.ts} | 3 +- ...curityHeaders.ts => 30-securityHeaders.ts} | 11 ++-- ...preprocessHtml.ts => 40-preprocessHtml.ts} | 6 ++- ...ntegrity.ts => 50-subresourceIntegrity.ts} | 6 ++- ...{04-cspSsgHashes.ts => 60-cspSsgHashes.ts} | 5 +- ...5-cspSsgPresets.ts => 70-cspSsgPresets.ts} | 0 .../{99-cspSsrNonce.ts => 80-cspSsrNonce.ts} | 6 ++- ...b-recombineHtml.ts => 90-recombineHtml.ts} | 6 ++- .../middleware/allowedMethodsRestricter.ts | 5 +- src/runtime/server/middleware/corsHandler.ts | 5 +- src/runtime/server/middleware/rateLimiter.ts | 5 +- .../server/middleware/requestSizeLimiter.ts | 5 +- src/runtime/server/middleware/xssValidator.ts | 5 +- src/types/headers.ts | 12 ----- src/types/index.ts | 11 +++- 20 files changed, 111 insertions(+), 96 deletions(-) create mode 100644 src/runtime/composables/context.ts rename src/runtime/nitro/plugins/{00-context.ts => 00-router.ts} (70%) create mode 100644 src/runtime/nitro/plugins/10-nonce.ts rename src/runtime/nitro/plugins/{01-hidePoweredBy.ts => 20-hidePoweredBy.ts} (72%) rename src/runtime/nitro/plugins/{02-securityHeaders.ts => 30-securityHeaders.ts} (81%) rename src/runtime/nitro/plugins/{02a-preprocessHtml.ts => 40-preprocessHtml.ts} (81%) rename src/runtime/nitro/plugins/{03-subresourceIntegrity.ts => 50-subresourceIntegrity.ts} (93%) rename src/runtime/nitro/plugins/{04-cspSsgHashes.ts => 60-cspSsgHashes.ts} (97%) rename src/runtime/nitro/plugins/{05-cspSsgPresets.ts => 70-cspSsgPresets.ts} (100%) rename src/runtime/nitro/plugins/{99-cspSsrNonce.ts => 80-cspSsrNonce.ts} (85%) rename src/runtime/nitro/plugins/{99b-recombineHtml.ts => 90-recombineHtml.ts} (78%) diff --git a/src/module.ts b/src/module.ts index 32b18bc1..c7daabad 100644 --- a/src/module.ts +++ b/src/module.ts @@ -32,7 +32,7 @@ declare module 'nuxt/schema' { declare module 'nitropack' { interface NitroRouteConfig { - security?: Partial; + security?: NuxtSecurityRouteRules; } } @@ -81,35 +81,38 @@ export default defineNuxtModule({ } ) - // Register nitro plugin to add security context to Nitro context - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-context')) + // Register nitro plugin to add security route rules to Nitro context + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-router')) + + // Register nitro plugin to add nonce + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/10-nonce')) // Register nitro plugin to hide X-Powered-By header - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/01-hidePoweredBy')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/20-hidePoweredBy')) // Register nitro plugin to enable Security Headers - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02-securityHeaders')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/30-securityHeaders')) // Pre-process HTML into DOM tree - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/02a-preprocessHtml')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/40-preprocessHtml')) // Register nitro plugin to enable Subresource Integrity - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/03-subresourceIntegrity')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/50-subresourceIntegrity')) // Register nitro plugin to enable CSP Hashes for SSG - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/04-cspSsgHashes')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/60-cspSsgHashes')) // Register nitro plugin to enable CSP Headers presets for SSG // TEMPORARILY DISABLED AS NUXT 3.9.3 PREVENTS IMPORTING @NUXT/KIT IN NITRO PLUGINS /* - addServerPlugin(resolve('./runtime/nitro/plugins/05-cspSsgPresets')) + addServerPlugin(resolve('./runtime/nitro/plugins/70-cspSsgPresets')) */ // Nitro plugin to enable CSP Nonce for SSR - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99-cspSsrNonce')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/80-cspSsrNonce')) // Recombine HTML from DOM tree - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/99b-recombineHtml')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/90-recombineHtml')) addServerHandler({ handler: resolver.resolve('./runtime/server/middleware/requestSizeLimiter') @@ -184,9 +187,7 @@ function registerStorageDriver(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 - nuxt.hook('nitro:init', nitro => { - - + nuxt.hook('nitro:init', nitro => { const resolver = createResolver(import.meta.url) const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') diff --git a/src/runtime/composables/context.ts b/src/runtime/composables/context.ts new file mode 100644 index 00000000..c60a3070 --- /dev/null +++ b/src/runtime/composables/context.ts @@ -0,0 +1,15 @@ + +import type { NuxtSecurityRouteRules } from "../../../src/types" +import { defuReplaceArray } from "../../../src/utils" +import { createRouter, toRouteMatcher } from "radix3" +import type { H3Event } from "h3" + +export function resolveSecurityRules(event: H3Event) { + const routeRules = event.context.security?.routeRules + const router = createRouter({ routes: routeRules}) + const matcher = toRouteMatcher(router) + const matches = matcher.matchAll(event.path.split('?')[0]) + const rules: NuxtSecurityRouteRules = defuReplaceArray({}, ...matches.reverse()) + + return rules +} \ No newline at end of file diff --git a/src/runtime/composables/nonce.ts b/src/runtime/composables/nonce.ts index 7963917e..2d38a940 100644 --- a/src/runtime/composables/nonce.ts +++ b/src/runtime/composables/nonce.ts @@ -1,5 +1,5 @@ import { useNuxtApp } from '#imports' export function useNonce () { - return useNuxtApp().ssrContext?.event?.context.nonce as string + return useNuxtApp().ssrContext?.event?.context.security.nonce as string } diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-router.ts similarity index 70% rename from src/runtime/nitro/plugins/00-context.ts rename to src/runtime/nitro/plugins/00-router.ts index 9604747c..36b0c0d4 100644 --- a/src/runtime/nitro/plugins/00-context.ts +++ b/src/runtime/nitro/plugins/00-router.ts @@ -1,20 +1,13 @@ -import { getNameFromKey, headerStringFromObject, headerObjectFromString, getKeyFromName } from "../../utils/headers" -import { createRouter, toRouteMatcher } from "radix3" -import { defineNitroPlugin, setResponseHeader, removeResponseHeader, getRouteRules, useRuntimeConfig } from "#imports" -import { ContentSecurityPolicyValue, OptionKey, SecurityHeaders, NuxtSecurityRouteRules } from "~/src/module" +import { defineNitroPlugin, useRuntimeConfig } from "#imports" +import { NuxtSecurityRouteRules } from "../../../types" import { defuReplaceArray } from "../../../utils" -import crypto from 'node:crypto' -import type { H3Event } from "h3" -import { Nuxt } from "@nuxt/schema" -import defu from "defu" - - +import { OptionKey, SecurityHeaders } from "../../../types/headers" +import { getKeyFromName, headerObjectFromString } from "../../utils/headers" export default defineNitroPlugin((nitroApp) => { const config = useRuntimeConfig() - - const securityRouteRules: Record> = {} + const securityRouteRules: Record = {} // First insert standard route rules headers for (const route in config.nitro.routeRules) { @@ -31,18 +24,7 @@ export default defineNitroPlugin((nitroApp) => { securityOptions, securityRouteRules['/**'] ) - /* - securityRouteRules['/api/**'] = defuReplaceArray( - { headers: false }, - securityRouteRules['/api/**'] - ) - securityRouteRules['/_nuxt/**'] = defuReplaceArray( - { headers: false }, - securityRouteRules['/_nuxt/**'] - ) - */ - - + // Then insert route specific security headers for (const route in config.nitro.routeRules) { const rule = config.nitro.routeRules[route] @@ -61,31 +43,15 @@ export default defineNitroPlugin((nitroApp) => { } } - const router = createRouter>({ - routes: securityRouteRules, - }) - - nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { securityRouteRules[route] = defuReplaceArray( { headers }, securityRouteRules[route] ) - router.insert(route, securityRouteRules[route]) }) - nitroApp.hooks.hook('request', (event) => { - const matcher = toRouteMatcher(router) - const matches = matcher.matchAll(event.path.split('?')[0]) - const rules: Partial = defuReplaceArray({}, ...matches.reverse()) - - event.context.security = { rules } - - if (rules.nonce) { - const nonce = crypto.randomBytes(16).toString('base64') - event.context.security.nonce = nonce - } + event.context.security = { routeRules: securityRouteRules } }) nitroApp.hooks.callHook('nuxt-security:ready') @@ -137,6 +103,4 @@ function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders) { } else { return undefined } -} - - +} \ No newline at end of file diff --git a/src/runtime/nitro/plugins/10-nonce.ts b/src/runtime/nitro/plugins/10-nonce.ts new file mode 100644 index 00000000..e38c2b3d --- /dev/null +++ b/src/runtime/nitro/plugins/10-nonce.ts @@ -0,0 +1,18 @@ + +import { defineNitroPlugin } from "#imports" +import crypto from 'node:crypto' +import { resolveSecurityRules } from "../../composables/context" + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + const rules = resolveSecurityRules(event) + if (rules.nonce) { + const nonce = crypto.randomBytes(16).toString('base64') + event.context.security.nonce = nonce + } + }) +}) + + + + diff --git a/src/runtime/nitro/plugins/01-hidePoweredBy.ts b/src/runtime/nitro/plugins/20-hidePoweredBy.ts similarity index 72% rename from src/runtime/nitro/plugins/01-hidePoweredBy.ts rename to src/runtime/nitro/plugins/20-hidePoweredBy.ts index 7bb7df3a..510e0f68 100644 --- a/src/runtime/nitro/plugins/01-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/20-hidePoweredBy.ts @@ -1,8 +1,9 @@ import { defineNitroPlugin, removeResponseHeader } from '#imports' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('beforeResponse', (event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (rules.hidePoweredBy && !event.node.res.headersSent) { removeResponseHeader(event, 'x-powered-by') } diff --git a/src/runtime/nitro/plugins/02-securityHeaders.ts b/src/runtime/nitro/plugins/30-securityHeaders.ts similarity index 81% rename from src/runtime/nitro/plugins/02-securityHeaders.ts rename to src/runtime/nitro/plugins/30-securityHeaders.ts index e9206d8c..ec0fe610 100644 --- a/src/runtime/nitro/plugins/02-securityHeaders.ts +++ b/src/runtime/nitro/plugins/30-securityHeaders.ts @@ -1,11 +1,16 @@ -import { getRouteRules, defineNitroPlugin, setResponseHeader, getResponseHeader, removeResponseHeader } from '#imports' +import { defineNitroPlugin, setResponseHeader, removeResponseHeader } from '#imports' import { ContentSecurityPolicyValue, type OptionKey } from '../../../types/headers' import { getNameFromKey, headerStringFromObject } from '../../utils/headers' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:response', (response, { event }) => { - const nonce = event.context.security.nonce - const headers = { ...event.context.security.rules.headers } + const rules = resolveSecurityRules(event) + + const headers = { ...rules.headers } + // const { rules } = event.context.security + const nonce = event.context.security?.nonce + // const headers = { ...event.context.security.rules.headers } if (headers && headers.contentSecurityPolicy) { const csp = headers.contentSecurityPolicy diff --git a/src/runtime/nitro/plugins/02a-preprocessHtml.ts b/src/runtime/nitro/plugins/40-preprocessHtml.ts similarity index 81% rename from src/runtime/nitro/plugins/02a-preprocessHtml.ts rename to src/runtime/nitro/plugins/40-preprocessHtml.ts index 817e93b0..584dfd42 100644 --- a/src/runtime/nitro/plugins/02a-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/40-preprocessHtml.ts @@ -1,12 +1,14 @@ -import { defineNitroPlugin, getRouteRules } from '#imports' +import { defineNitroPlugin } from '#imports' import * as cheerio from 'cheerio/lib/slim' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async (html, { event }) => { // Exit if no need to parse HTML for this route - const { rules } = event.context.security + const rules = resolveSecurityRules(event) + // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules?.headers.contentSecurityPolicy)) { return } diff --git a/src/runtime/nitro/plugins/03-subresourceIntegrity.ts b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts similarity index 93% rename from src/runtime/nitro/plugins/03-subresourceIntegrity.ts rename to src/runtime/nitro/plugins/50-subresourceIntegrity.ts index 038bd9d0..75f27716 100644 --- a/src/runtime/nitro/plugins/03-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts @@ -1,12 +1,14 @@ -import { useStorage, defineNitroPlugin, getRouteRules } from '#imports' +import { useStorage, defineNitroPlugin } from '#imports' import { isPrerendering } from '../utils' import { type CheerioAPI } from 'cheerio' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async (html, { event }) => { // Exit if SRI not enabled for this route - const { rules } = event.context.security + const rules = resolveSecurityRules(event) + // const { rules } = event.context.security if (!rules?.sri) { return } diff --git a/src/runtime/nitro/plugins/04-cspSsgHashes.ts b/src/runtime/nitro/plugins/60-cspSsgHashes.ts similarity index 97% rename from src/runtime/nitro/plugins/04-cspSsgHashes.ts rename to src/runtime/nitro/plugins/60-cspSsgHashes.ts index 3f8bc4fd..9b257136 100644 --- a/src/runtime/nitro/plugins/04-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/60-cspSsgHashes.ts @@ -1,9 +1,10 @@ -import { defineNitroPlugin, getRouteRules, setResponseHeader } from '#imports' +import { defineNitroPlugin, setResponseHeader } from '#imports' import * as cheerio from 'cheerio' import type { ContentSecurityPolicyValue } from '~/src/module' import { headerStringFromObject } from '../../utils/headers' import { generateHash } from '../../utils/hashes' import { isPrerendering } from '../utils' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { @@ -14,7 +15,7 @@ export default defineNitroPlugin((nitroApp) => { } // Exit if no CSP defined - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } diff --git a/src/runtime/nitro/plugins/05-cspSsgPresets.ts b/src/runtime/nitro/plugins/70-cspSsgPresets.ts similarity index 100% rename from src/runtime/nitro/plugins/05-cspSsgPresets.ts rename to src/runtime/nitro/plugins/70-cspSsgPresets.ts diff --git a/src/runtime/nitro/plugins/99-cspSsrNonce.ts b/src/runtime/nitro/plugins/80-cspSsrNonce.ts similarity index 85% rename from src/runtime/nitro/plugins/99-cspSsrNonce.ts rename to src/runtime/nitro/plugins/80-cspSsrNonce.ts index 952e41ce..53a4aefd 100644 --- a/src/runtime/nitro/plugins/99-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/80-cspSsrNonce.ts @@ -1,6 +1,7 @@ -import { defineNitroPlugin, getRouteRules, setResponseHeader, getResponseHeaders } from '#imports' +import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' import { isPrerendering } from '../utils' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { @@ -11,7 +12,8 @@ export default defineNitroPlugin((nitroApp) => { } // Exit if no CSP defined - const { rules } = event.context.security + const rules = resolveSecurityRules(event) + // const { rules } = event.context.security if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } diff --git a/src/runtime/nitro/plugins/99b-recombineHtml.ts b/src/runtime/nitro/plugins/90-recombineHtml.ts similarity index 78% rename from src/runtime/nitro/plugins/99b-recombineHtml.ts rename to src/runtime/nitro/plugins/90-recombineHtml.ts index 90f3ee99..114fdaac 100644 --- a/src/runtime/nitro/plugins/99b-recombineHtml.ts +++ b/src/runtime/nitro/plugins/90-recombineHtml.ts @@ -1,12 +1,14 @@ -import { defineNitroPlugin, getRouteRules } from '#imports' +import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' +import { resolveSecurityRules } from '../../composables/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit if no need to parse HTML for this route - const { rules } = event.context.security + const rules = resolveSecurityRules(event) + // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules.headers.contentSecurityPolicy)) { return } diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index 32d15f61..7c110c44 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -1,8 +1,9 @@ -import { getRouteRules, defineEventHandler, createError } from '#imports' +import { defineEventHandler, createError } from '#imports' import { HTTPMethod } from '~/src/module' +import { resolveSecurityRules } from '../../composables/context' export default defineEventHandler((event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (rules?.allowedMethodsRestricter) { const { allowedMethodsRestricter } = rules diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts index 46e32574..968f4b57 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -1,8 +1,9 @@ -import { getRouteRules, defineEventHandler, handleCors } from '#imports' +import { defineEventHandler, handleCors } from '#imports' import type { H3CorsOptions } from 'h3' +import { resolveSecurityRules } from '../../composables/context' export default defineEventHandler((event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (rules?.corsHandler) { const { corsHandler } = rules diff --git a/src/runtime/server/middleware/rateLimiter.ts b/src/runtime/server/middleware/rateLimiter.ts index a1541276..a03c86df 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -1,6 +1,7 @@ import type { H3Event } from 'h3' -import { defineEventHandler, getRequestHeader, createError, setResponseHeader, getRouteRules, useStorage } from '#imports' +import { defineEventHandler, getRequestHeader, createError, setResponseHeader, useStorage } from '#imports' import type { RateLimiter } from '~/src/module' +import { resolveSecurityRules } from '../../composables/context' type StorageItem = { value: number, @@ -10,7 +11,7 @@ type StorageItem = { const storage = useStorage('#storage-driver') export default defineEventHandler(async (event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (rules?.rateLimiter) { const { rateLimiter } = rules diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index cb32076f..c855481c 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -1,9 +1,10 @@ -import { defineEventHandler, getRequestHeader, createError, getRouteRules } from '#imports' +import { defineEventHandler, getRequestHeader, createError } from '#imports' +import { resolveSecurityRules } from '../../composables/context' const FILE_UPLOAD_HEADER = 'multipart/form-data' export default defineEventHandler((event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) if (rules?.requestSizeLimiter) { if (['POST', 'PUT', 'DELETE'].includes(event.node.req.method!)) { diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index 1e991e05..83878ff0 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -5,12 +5,13 @@ import { getQuery, readBody, readMultipartFormData, - getRouteRules } from '#imports' import { HTTPMethod } from '~/src/module' +import { resolveSecurityRules } from '../../composables/context' export default defineEventHandler(async (event) => { - const { rules } = event.context.security + const rules = resolveSecurityRules(event) + if (rules?.xssValidator) { const filterOpt: IFilterXSSOptions = { ...rules.xssValidator, diff --git a/src/types/headers.ts b/src/types/headers.ts index 2f4e84e8..d25d5c62 100644 --- a/src/types/headers.ts +++ b/src/types/headers.ts @@ -1,5 +1,3 @@ -import type { NuxtSecurityRouteRules } from "."; - export type CrossOriginResourcePolicyValue = 'same-site' | 'same-origin' | 'cross-origin'; export type CrossOriginOpenerPolicyValue = 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin'; @@ -245,13 +243,3 @@ export interface SecurityHeaders { xXSSProtection?: string | false; permissionsPolicy?: PermissionsPolicyValue | false; } - - -declare module 'h3' { - interface H3EventContext { - security: { - rules: Partial; - nonce?: string; - } - } -} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b14af497..c87a1be2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,4 +47,13 @@ declare module 'nitropack' { }) => void 'nuxt-security:ready': () => void } -} \ No newline at end of file +} + +declare module 'h3' { + interface H3EventContext { + security: { + routeRules?: Record; + nonce?: string; + } + } +} \ No newline at end of file From a39591a17ba9b6072ab9ced6175ac5f35aa51974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Wed, 17 Apr 2024 23:16:03 +0200 Subject: [PATCH 09/21] await resolveSecurityRules everywhere --- playground/nuxt.config.ts | 2 +- playground/server/api/runtime-headers.ts | 2 -- playground/server/plugins/headers2.ts | 10 ++++++++++ src/module.ts | 5 ++++- src/runtime/composables/nonce.ts | 2 +- src/runtime/nitro/plugins/10-nonce.ts | 6 +++--- src/runtime/nitro/plugins/20-hidePoweredBy.ts | 6 +++--- src/runtime/nitro/plugins/30-securityHeaders.ts | 7 +++---- src/runtime/nitro/plugins/40-preprocessHtml.ts | 6 +++--- .../nitro/plugins/50-subresourceIntegrity.ts | 8 ++++---- src/runtime/nitro/plugins/60-cspSsgHashes.ts | 10 +++++----- src/runtime/nitro/plugins/80-cspSsrNonce.ts | 17 ++++++----------- src/runtime/nitro/plugins/90-recombineHtml.ts | 6 +++--- .../{composables => nitro/utils}/context.ts | 10 ++++++---- src/runtime/nitro/utils/index.ts | 10 +++++----- .../middleware/allowedMethodsRestricter.ts | 6 +++--- src/runtime/server/middleware/corsHandler.ts | 6 +++--- src/runtime/server/middleware/rateLimiter.ts | 4 ++-- .../server/middleware/requestSizeLimiter.ts | 6 +++--- src/runtime/server/middleware/xssValidator.ts | 4 ++-- src/types/index.ts | 1 + 21 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 playground/server/plugins/headers2.ts rename src/runtime/{composables => nitro/utils}/context.ts (58%) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index a953131b..7f93b25f 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -29,7 +29,7 @@ export default defineNuxtConfig({ xXSSProtection: '0' }, rateLimiter: { - tokensPerInterval: 10, + tokensPerInterval: 1000, interval: 10000 }, runtimeHooks: true, diff --git a/playground/server/api/runtime-headers.ts b/playground/server/api/runtime-headers.ts index 800ea14a..d559c5eb 100644 --- a/playground/server/api/runtime-headers.ts +++ b/playground/server/api/runtime-headers.ts @@ -1,5 +1,3 @@ -import { defineEventHandler } from "#imports" - export default defineEventHandler((event) => { const time = new Date().toISOString() return { diff --git a/playground/server/plugins/headers2.ts b/playground/server/plugins/headers2.ts new file mode 100644 index 00000000..50241694 --- /dev/null +++ b/playground/server/plugins/headers2.ts @@ -0,0 +1,10 @@ +import defu from "defu" + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('nuxt-security:rules', rules => { + rules.headers = defu( + { contentSecurityPolicy: { 'upgrade-insecure-requests': false } }, + rules.headers + ) + }) +}) \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index c7daabad..eba5d61a 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver } from '@nuxt/kit' +import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver, addImportsDir } from '@nuxt/kit' import { defu } from 'defu' import type { Nuxt } from '@nuxt/schema' import viteRemove from 'unplugin-remove/vite' @@ -148,9 +148,12 @@ export default defineNuxtModule({ nuxt.hook('nitro:build:before', hashBundledAssets) // Import composables + addImportsDir(resolver.resolve('./runtime/composables')) + /* nuxt.hook('imports:dirs', (dirs) => { dirs.push(resolver.resolve('./runtime/composables')) }) + */ // Import CSURF module const csrfConfig = nuxt.options.security.csrf diff --git a/src/runtime/composables/nonce.ts b/src/runtime/composables/nonce.ts index 2d38a940..83963ed1 100644 --- a/src/runtime/composables/nonce.ts +++ b/src/runtime/composables/nonce.ts @@ -1,5 +1,5 @@ import { useNuxtApp } from '#imports' export function useNonce () { - return useNuxtApp().ssrContext?.event?.context.security.nonce as string + return useNuxtApp().ssrContext?.event?.context.security.nonce } diff --git a/src/runtime/nitro/plugins/10-nonce.ts b/src/runtime/nitro/plugins/10-nonce.ts index e38c2b3d..9c08d142 100644 --- a/src/runtime/nitro/plugins/10-nonce.ts +++ b/src/runtime/nitro/plugins/10-nonce.ts @@ -1,11 +1,11 @@ import { defineNitroPlugin } from "#imports" import crypto from 'node:crypto' -import { resolveSecurityRules } from "../../composables/context" +import { resolveSecurityRules } from "../utils/context" export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('request', (event) => { - const rules = resolveSecurityRules(event) + nitroApp.hooks.hook('request', async(event) => { + const rules = await resolveSecurityRules(event) if (rules.nonce) { const nonce = crypto.randomBytes(16).toString('base64') event.context.security.nonce = nonce diff --git a/src/runtime/nitro/plugins/20-hidePoweredBy.ts b/src/runtime/nitro/plugins/20-hidePoweredBy.ts index 510e0f68..f073b74d 100644 --- a/src/runtime/nitro/plugins/20-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/20-hidePoweredBy.ts @@ -1,9 +1,9 @@ import { defineNitroPlugin, removeResponseHeader } from '#imports' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('beforeResponse', (event) => { - const rules = resolveSecurityRules(event) + nitroApp.hooks.hook('beforeResponse', async(event) => { + const rules = await resolveSecurityRules(event) if (rules.hidePoweredBy && !event.node.res.headersSent) { removeResponseHeader(event, 'x-powered-by') } diff --git a/src/runtime/nitro/plugins/30-securityHeaders.ts b/src/runtime/nitro/plugins/30-securityHeaders.ts index ec0fe610..eb605ec3 100644 --- a/src/runtime/nitro/plugins/30-securityHeaders.ts +++ b/src/runtime/nitro/plugins/30-securityHeaders.ts @@ -1,12 +1,11 @@ import { defineNitroPlugin, setResponseHeader, removeResponseHeader } from '#imports' import { ContentSecurityPolicyValue, type OptionKey } from '../../../types/headers' import { getNameFromKey, headerStringFromObject } from '../../utils/headers' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:response', (response, { event }) => { - const rules = resolveSecurityRules(event) - + nitroApp.hooks.hook('render:response', async(response, { event }) => { + const rules = await resolveSecurityRules(event) const headers = { ...rules.headers } // const { rules } = event.context.security const nonce = event.context.security?.nonce diff --git a/src/runtime/nitro/plugins/40-preprocessHtml.ts b/src/runtime/nitro/plugins/40-preprocessHtml.ts index 584dfd42..7600a9d1 100644 --- a/src/runtime/nitro/plugins/40-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/40-preprocessHtml.ts @@ -1,13 +1,13 @@ import { defineNitroPlugin } from '#imports' import * as cheerio from 'cheerio/lib/slim' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async (html, { event }) => { + nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit if no need to parse HTML for this route - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules?.headers.contentSecurityPolicy)) { return diff --git a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts index 75f27716..cf23d9e0 100644 --- a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts @@ -1,13 +1,13 @@ import { useStorage, defineNitroPlugin } from '#imports' -import { isPrerendering } from '../utils' +//import { isPrerendering } from '../utils' import { type CheerioAPI } from 'cheerio' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async (html, { event }) => { // Exit if SRI not enabled for this route - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri) { return @@ -22,7 +22,7 @@ export default defineNitroPlugin((nitroApp) => { // - 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 prerendering = isPrerendering(event) + const prerendering = !!import.meta.prerender const storageBase = prerendering ? 'build' : 'assets' const sriHashes = await useStorage(storageBase).getItem>('integrity:sriHashes.json') || {} diff --git a/src/runtime/nitro/plugins/60-cspSsgHashes.ts b/src/runtime/nitro/plugins/60-cspSsgHashes.ts index 9b257136..b182614e 100644 --- a/src/runtime/nitro/plugins/60-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/60-cspSsgHashes.ts @@ -3,19 +3,19 @@ import * as cheerio from 'cheerio' import type { ContentSecurityPolicyValue } from '~/src/module' import { headerStringFromObject } from '../../utils/headers' import { generateHash } from '../../utils/hashes' -import { isPrerendering } from '../utils' -import { resolveSecurityRules } from '../../composables/context' +//import { isPrerendering } from '../utils' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', (html, { event }) => { + nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit in SSR mode - if (!isPrerendering(event)) { + if (!import.meta.prerender) { return } // Exit if no CSP defined - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } diff --git a/src/runtime/nitro/plugins/80-cspSsrNonce.ts b/src/runtime/nitro/plugins/80-cspSsrNonce.ts index 53a4aefd..84ffd792 100644 --- a/src/runtime/nitro/plugins/80-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/80-cspSsrNonce.ts @@ -1,28 +1,26 @@ import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' -import { isPrerendering } from '../utils' -import { resolveSecurityRules } from '../../composables/context' +//import { isPrerendering } from '../utils' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', (html, { event }) => { + nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit in SSG mode - if (isPrerendering(event)) { + if (import.meta.prerender) { return } // Exit if no CSP defined - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } - let nonce: string | undefined; - // Parse HTML if nonce is enabled for this route if (rules.nonce) { - nonce = event.context.security.nonce + const nonce = event.context.security.nonce // Scan all relevant sections of the NuxtRenderHtmlContext type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] @@ -38,8 +36,5 @@ export default defineNitroPlugin((nitroApp) => { }) } } - }) - - }) diff --git a/src/runtime/nitro/plugins/90-recombineHtml.ts b/src/runtime/nitro/plugins/90-recombineHtml.ts index 114fdaac..7990dedc 100644 --- a/src/runtime/nitro/plugins/90-recombineHtml.ts +++ b/src/runtime/nitro/plugins/90-recombineHtml.ts @@ -1,13 +1,13 @@ import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../utils/context' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', (html, { event }) => { + nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit if no need to parse HTML for this route - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules.headers.contentSecurityPolicy)) { return diff --git a/src/runtime/composables/context.ts b/src/runtime/nitro/utils/context.ts similarity index 58% rename from src/runtime/composables/context.ts rename to src/runtime/nitro/utils/context.ts index c60a3070..3a18800d 100644 --- a/src/runtime/composables/context.ts +++ b/src/runtime/nitro/utils/context.ts @@ -1,15 +1,17 @@ -import type { NuxtSecurityRouteRules } from "../../../src/types" -import { defuReplaceArray } from "../../../src/utils" +import type { NuxtSecurityRouteRules } from "../../../types" +import { defuReplaceArray } from "../../../utils" import { createRouter, toRouteMatcher } from "radix3" import type { H3Event } from "h3" +import { useNitroApp } from "#imports" -export function resolveSecurityRules(event: H3Event) { +export async function resolveSecurityRules(event: H3Event) { const routeRules = event.context.security?.routeRules const router = createRouter({ routes: routeRules}) const matcher = toRouteMatcher(router) const matches = matcher.matchAll(event.path.split('?')[0]) const rules: NuxtSecurityRouteRules = defuReplaceArray({}, ...matches.reverse()) - + const nitroApp = useNitroApp() + await nitroApp.hooks.callHook('nuxt-security:rules', rules) return rules } \ No newline at end of file diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index 60ee4937..f31caf14 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -5,8 +5,8 @@ import { getRequestHeader } from '#imports' * @param event H3Event * @returns boolean */ -export function isPrerendering(event: H3Event): boolean { - const isPrerendering = !!getRequestHeader(event, 'x-nitro-prerender') - // console.log('isPrerendering', isPrerendering, import.meta.prerender) - return isPrerendering -} \ No newline at end of file +export function isPrerendering(event: H3Event) { + // const isPrerendering = !!getRequestHeader(event, 'x-nitro-prerender') + const isPrerendering = import.meta.prerender + return !!isPrerendering +} diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index 7c110c44..f117bce4 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -1,9 +1,9 @@ import { defineEventHandler, createError } from '#imports' import { HTTPMethod } from '~/src/module' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../../nitro/utils/context' -export default defineEventHandler((event) => { - const rules = resolveSecurityRules(event) +export default defineEventHandler(async(event) => { + const rules = await resolveSecurityRules(event) if (rules?.allowedMethodsRestricter) { const { allowedMethodsRestricter } = rules diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts index 968f4b57..3bd9c782 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -1,9 +1,9 @@ import { defineEventHandler, handleCors } from '#imports' import type { H3CorsOptions } from 'h3' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../../nitro/utils/context' -export default defineEventHandler((event) => { - const rules = resolveSecurityRules(event) +export default defineEventHandler(async(event) => { + const rules = await resolveSecurityRules(event) if (rules?.corsHandler) { const { corsHandler } = rules diff --git a/src/runtime/server/middleware/rateLimiter.ts b/src/runtime/server/middleware/rateLimiter.ts index a03c86df..57eadf94 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -1,7 +1,7 @@ import type { H3Event } from 'h3' import { defineEventHandler, getRequestHeader, createError, setResponseHeader, useStorage } from '#imports' import type { RateLimiter } from '~/src/module' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../../nitro/utils/context' type StorageItem = { value: number, @@ -11,7 +11,7 @@ type StorageItem = { const storage = useStorage('#storage-driver') export default defineEventHandler(async (event) => { - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) if (rules?.rateLimiter) { const { rateLimiter } = rules diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index c855481c..7a73c402 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -1,10 +1,10 @@ import { defineEventHandler, getRequestHeader, createError } from '#imports' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../../nitro/utils/context' const FILE_UPLOAD_HEADER = 'multipart/form-data' -export default defineEventHandler((event) => { - const rules = resolveSecurityRules(event) +export default defineEventHandler(async(event) => { + const rules = await resolveSecurityRules(event) if (rules?.requestSizeLimiter) { if (['POST', 'PUT', 'DELETE'].includes(event.node.req.method!)) { diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index 83878ff0..a3ac053f 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -7,10 +7,10 @@ import { readMultipartFormData, } from '#imports' import { HTTPMethod } from '~/src/module' -import { resolveSecurityRules } from '../../composables/context' +import { resolveSecurityRules } from '../../nitro/utils/context' export default defineEventHandler(async (event) => { - const rules = resolveSecurityRules(event) + const rules = await resolveSecurityRules(event) if (rules?.xssValidator) { const filterOpt: IFilterXSSOptions = { diff --git a/src/types/index.ts b/src/types/index.ts index c87a1be2..f3fb3b1b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,7 @@ declare module 'nitropack' { headers: SecurityHeaders }) => void 'nuxt-security:ready': () => void + 'nuxt-security:rules': (rules: NuxtSecurityRouteRules) => void } } From 0caf4586196881fe67b45c67967edcc4ad54f75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Thu, 18 Apr 2024 17:02:46 +0200 Subject: [PATCH 10/21] introduce new routeRules hook make resolveSecurityRules synchronous --- playground/server/plugins/headers.ts | 2 ++ playground/server/plugins/headers2.ts | 8 ++--- src/module.ts | 2 +- .../{00-router.ts => 00-routeRules.ts} | 10 +++++-- src/runtime/nitro/plugins/10-nonce.ts | 6 ++-- src/runtime/nitro/plugins/20-hidePoweredBy.ts | 6 ++-- .../nitro/plugins/30-securityHeaders.ts | 8 ++--- .../nitro/plugins/40-preprocessHtml.ts | 6 ++-- .../nitro/plugins/50-subresourceIntegrity.ts | 6 ++-- src/runtime/nitro/plugins/60-cspSsgHashes.ts | 6 ++-- src/runtime/nitro/plugins/70-cspSsgPresets.ts | 3 +- src/runtime/nitro/plugins/80-cspSsrNonce.ts | 6 ++-- src/runtime/nitro/plugins/90-recombineHtml.ts | 6 ++-- src/runtime/nitro/utils/context.ts | 17 ----------- src/runtime/nitro/utils/index.ts | 26 +++++++++-------- .../middleware/allowedMethodsRestricter.ts | 6 ++-- src/runtime/server/middleware/corsHandler.ts | 6 ++-- src/runtime/server/middleware/rateLimiter.ts | 6 ++-- .../server/middleware/requestSizeLimiter.ts | 6 ++-- src/runtime/server/middleware/xssValidator.ts | 6 ++-- src/types/index.ts | 2 +- test/fixtures/runtimeHooks/nuxt.config.ts | 2 +- .../{dynamic.vue => headers-dynamic.vue} | 0 .../pages/{static.vue => headers-static.vue} | 0 .../runtimeHooks/pages/rules-dynamic.vue | 3 ++ .../runtimeHooks/pages/rules-static.vue | 3 ++ .../server/api/runtime-headers.ts | 4 +-- .../runtimeHooks/server/plugins/headers.ts | 6 ++-- .../runtimeHooks/server/plugins/routeRules.ts | 18 ++++++++++++ test/runtimeHooks.test.ts | 29 +++++++++++++++---- 30 files changed, 121 insertions(+), 94 deletions(-) rename src/runtime/nitro/plugins/{00-router.ts => 00-routeRules.ts} (93%) delete mode 100644 src/runtime/nitro/utils/context.ts rename test/fixtures/runtimeHooks/pages/{dynamic.vue => headers-dynamic.vue} (100%) rename test/fixtures/runtimeHooks/pages/{static.vue => headers-static.vue} (100%) create mode 100644 test/fixtures/runtimeHooks/pages/rules-dynamic.vue create mode 100644 test/fixtures/runtimeHooks/pages/rules-static.vue create mode 100644 test/fixtures/runtimeHooks/server/plugins/routeRules.ts diff --git a/playground/server/plugins/headers.ts b/playground/server/plugins/headers.ts index 017404a6..1ea51ed1 100644 --- a/playground/server/plugins/headers.ts +++ b/playground/server/plugins/headers.ts @@ -1,10 +1,12 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:ready', async() => { const { headers } = await $fetch('/api/runtime-headers') + /* nitroApp.hooks.callHook('nuxt-security:headers', { route: '/runtime', headers }) + */ }) }) \ No newline at end of file diff --git a/playground/server/plugins/headers2.ts b/playground/server/plugins/headers2.ts index 50241694..ff848f1f 100644 --- a/playground/server/plugins/headers2.ts +++ b/playground/server/plugins/headers2.ts @@ -1,10 +1,8 @@ import defu from "defu" export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('nuxt-security:rules', rules => { - rules.headers = defu( - { contentSecurityPolicy: { 'upgrade-insecure-requests': false } }, - rules.headers - ) + nitroApp.hooks.hook('nuxt-security:routeRules', async routeRules => { + const { headers } = await $fetch('/api/runtime-headers') + routeRules['/'] = { headers, nonce: false } }) }) \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index eba5d61a..2172beef 100644 --- a/src/module.ts +++ b/src/module.ts @@ -82,7 +82,7 @@ export default defineNuxtModule({ ) // Register nitro plugin to add security route rules to Nitro context - addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-router')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules')) // Register nitro plugin to add nonce addServerPlugin(resolver.resolve('./runtime/nitro/plugins/10-nonce')) diff --git a/src/runtime/nitro/plugins/00-router.ts b/src/runtime/nitro/plugins/00-routeRules.ts similarity index 93% rename from src/runtime/nitro/plugins/00-router.ts rename to src/runtime/nitro/plugins/00-routeRules.ts index 36b0c0d4..82b646f8 100644 --- a/src/runtime/nitro/plugins/00-router.ts +++ b/src/runtime/nitro/plugins/00-routeRules.ts @@ -43,18 +43,22 @@ export default defineNitroPlugin((nitroApp) => { } } + // TO DO : DEPRECATE IN FAVOR OF NUXT-SECURITY:ROUTERULES HOOK nitroApp.hooks.hook('nuxt-security:headers', ({ route, headers }) => { securityRouteRules[route] = defuReplaceArray( { headers }, securityRouteRules[route] ) }) + nitroApp.hooks.callHook('nuxt-security:ready') + + // NEW HOOK HAS ABILITY TO CONFIGURE ALL SECURITY OPTIONS FOR EACH ROUTE + nitroApp.hooks.callHook('nuxt-security:routeRules', securityRouteRules) + - nitroApp.hooks.hook('request', (event) => { + nitroApp.hooks.hook('request', async(event) => { event.context.security = { routeRules: securityRouteRules } }) - - nitroApp.hooks.callHook('nuxt-security:ready') }) function standardToSecurity(standardHeaders?: Record) { diff --git a/src/runtime/nitro/plugins/10-nonce.ts b/src/runtime/nitro/plugins/10-nonce.ts index 9c08d142..d0e159fa 100644 --- a/src/runtime/nitro/plugins/10-nonce.ts +++ b/src/runtime/nitro/plugins/10-nonce.ts @@ -1,11 +1,11 @@ import { defineNitroPlugin } from "#imports" import crypto from 'node:crypto' -import { resolveSecurityRules } from "../utils/context" +import { resolveSecurityRules } from "../utils" export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('request', async(event) => { - const rules = await resolveSecurityRules(event) + nitroApp.hooks.hook('request', (event) => { + const rules = resolveSecurityRules(event) if (rules.nonce) { const nonce = crypto.randomBytes(16).toString('base64') event.context.security.nonce = nonce diff --git a/src/runtime/nitro/plugins/20-hidePoweredBy.ts b/src/runtime/nitro/plugins/20-hidePoweredBy.ts index f073b74d..618dca31 100644 --- a/src/runtime/nitro/plugins/20-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/20-hidePoweredBy.ts @@ -1,9 +1,9 @@ import { defineNitroPlugin, removeResponseHeader } from '#imports' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('beforeResponse', async(event) => { - const rules = await resolveSecurityRules(event) + nitroApp.hooks.hook('beforeResponse', (event) => { + const rules = resolveSecurityRules(event) if (rules.hidePoweredBy && !event.node.res.headersSent) { removeResponseHeader(event, 'x-powered-by') } diff --git a/src/runtime/nitro/plugins/30-securityHeaders.ts b/src/runtime/nitro/plugins/30-securityHeaders.ts index eb605ec3..ed0927a1 100644 --- a/src/runtime/nitro/plugins/30-securityHeaders.ts +++ b/src/runtime/nitro/plugins/30-securityHeaders.ts @@ -1,15 +1,13 @@ import { defineNitroPlugin, setResponseHeader, removeResponseHeader } from '#imports' import { ContentSecurityPolicyValue, type OptionKey } from '../../../types/headers' import { getNameFromKey, headerStringFromObject } from '../../utils/headers' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:response', async(response, { event }) => { - const rules = await resolveSecurityRules(event) + nitroApp.hooks.hook('render:response', (response, { event }) => { + const rules = resolveSecurityRules(event) const headers = { ...rules.headers } - // const { rules } = event.context.security const nonce = event.context.security?.nonce - // const headers = { ...event.context.security.rules.headers } if (headers && headers.contentSecurityPolicy) { const csp = headers.contentSecurityPolicy diff --git a/src/runtime/nitro/plugins/40-preprocessHtml.ts b/src/runtime/nitro/plugins/40-preprocessHtml.ts index 7600a9d1..9d6eb93e 100644 --- a/src/runtime/nitro/plugins/40-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/40-preprocessHtml.ts @@ -1,13 +1,13 @@ import { defineNitroPlugin } from '#imports' import * as cheerio from 'cheerio/lib/slim' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async(html, { event }) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit if no need to parse HTML for this route - const rules = await resolveSecurityRules(event) + const rules = resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules?.headers.contentSecurityPolicy)) { return diff --git a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts index cf23d9e0..56072a22 100644 --- a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts @@ -1,13 +1,13 @@ import { useStorage, defineNitroPlugin } from '#imports' //import { isPrerendering } from '../utils' import { type CheerioAPI } from 'cheerio' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async (html, { event }) => { + nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit if SRI not enabled for this route - const rules = await resolveSecurityRules(event) + const rules = resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri) { return diff --git a/src/runtime/nitro/plugins/60-cspSsgHashes.ts b/src/runtime/nitro/plugins/60-cspSsgHashes.ts index b182614e..145369ff 100644 --- a/src/runtime/nitro/plugins/60-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/60-cspSsgHashes.ts @@ -4,18 +4,18 @@ import type { ContentSecurityPolicyValue } from '~/src/module' import { headerStringFromObject } from '../../utils/headers' import { generateHash } from '../../utils/hashes' //import { isPrerendering } from '../utils' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async(html, { event }) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit in SSR mode if (!import.meta.prerender) { return } // Exit if no CSP defined - const rules = await resolveSecurityRules(event) + const rules = resolveSecurityRules(event) if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return } diff --git a/src/runtime/nitro/plugins/70-cspSsgPresets.ts b/src/runtime/nitro/plugins/70-cspSsgPresets.ts index 3ef6e50a..420f1e2e 100644 --- a/src/runtime/nitro/plugins/70-cspSsgPresets.ts +++ b/src/runtime/nitro/plugins/70-cspSsgPresets.ts @@ -1,7 +1,6 @@ import { defineNitroPlugin, getResponseHeaders } from '#imports' import { tryUseNuxt, useNitro } from '@nuxt/kit' import { defu } from 'defu' -import { isPrerendering } from '../utils' export default defineNitroPlugin((nitroApp) => { @@ -11,7 +10,7 @@ export default defineNitroPlugin((nitroApp) => { return } // Exit in SSR mode - if (!isPrerendering(event)) { + if (!import.meta.prerender) { return } diff --git a/src/runtime/nitro/plugins/80-cspSsrNonce.ts b/src/runtime/nitro/plugins/80-cspSsrNonce.ts index 84ffd792..ac2b3d83 100644 --- a/src/runtime/nitro/plugins/80-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/80-cspSsrNonce.ts @@ -1,18 +1,18 @@ import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' //import { isPrerendering } from '../utils' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async(html, { event }) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit in SSG mode if (import.meta.prerender) { return } // Exit if no CSP defined - const rules = await resolveSecurityRules(event) + const rules = resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.headers || !rules.headers.contentSecurityPolicy) { return diff --git a/src/runtime/nitro/plugins/90-recombineHtml.ts b/src/runtime/nitro/plugins/90-recombineHtml.ts index 7990dedc..6cc058c0 100644 --- a/src/runtime/nitro/plugins/90-recombineHtml.ts +++ b/src/runtime/nitro/plugins/90-recombineHtml.ts @@ -1,13 +1,13 @@ import { defineNitroPlugin } from '#imports' import { type CheerioAPI } from 'cheerio' -import { resolveSecurityRules } from '../utils/context' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('render:html', async(html, { event }) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit if no need to parse HTML for this route - const rules = await resolveSecurityRules(event) + const rules = resolveSecurityRules(event) // const { rules } = event.context.security if (!rules?.sri && (!rules?.headers || !rules.headers.contentSecurityPolicy)) { return diff --git a/src/runtime/nitro/utils/context.ts b/src/runtime/nitro/utils/context.ts deleted file mode 100644 index 3a18800d..00000000 --- a/src/runtime/nitro/utils/context.ts +++ /dev/null @@ -1,17 +0,0 @@ - -import type { NuxtSecurityRouteRules } from "../../../types" -import { defuReplaceArray } from "../../../utils" -import { createRouter, toRouteMatcher } from "radix3" -import type { H3Event } from "h3" -import { useNitroApp } from "#imports" - -export async function resolveSecurityRules(event: H3Event) { - const routeRules = event.context.security?.routeRules - const router = createRouter({ routes: routeRules}) - const matcher = toRouteMatcher(router) - const matches = matcher.matchAll(event.path.split('?')[0]) - const rules: NuxtSecurityRouteRules = defuReplaceArray({}, ...matches.reverse()) - const nitroApp = useNitroApp() - await nitroApp.hooks.callHook('nuxt-security:rules', rules) - return rules -} \ No newline at end of file diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index f31caf14..c8fb3851 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -1,12 +1,14 @@ -import type { H3Event } from 'h3' -import { getRequestHeader } from '#imports' -/** - * Detect if page is being pre-rendered - * @param event H3Event - * @returns boolean - */ -export function isPrerendering(event: H3Event) { - // const isPrerendering = !!getRequestHeader(event, 'x-nitro-prerender') - const isPrerendering = import.meta.prerender - return !!isPrerendering -} + +import type { NuxtSecurityRouteRules } from "../../../types" +import { createRouter, toRouteMatcher } from "radix3" +import type { H3Event } from "h3" +import defu from "defu" + +export function resolveSecurityRules(event: H3Event) { + const routeRules = event.context.security?.routeRules + const router = createRouter({ routes: routeRules}) + const matcher = toRouteMatcher(router) + const matches = matcher.matchAll(event.path.split('?')[0]) + const rules: NuxtSecurityRouteRules = defu({}, ...matches.reverse()) + return rules +} \ No newline at end of file diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index f117bce4..001f7867 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -1,9 +1,9 @@ import { defineEventHandler, createError } from '#imports' import { HTTPMethod } from '~/src/module' -import { resolveSecurityRules } from '../../nitro/utils/context' +import { resolveSecurityRules } from '../../nitro/utils' -export default defineEventHandler(async(event) => { - const rules = await resolveSecurityRules(event) +export default defineEventHandler((event) => { + const rules = resolveSecurityRules(event) if (rules?.allowedMethodsRestricter) { const { allowedMethodsRestricter } = rules diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts index 3bd9c782..2bcd8536 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -1,9 +1,9 @@ import { defineEventHandler, handleCors } from '#imports' import type { H3CorsOptions } from 'h3' -import { resolveSecurityRules } from '../../nitro/utils/context' +import { resolveSecurityRules } from '../../nitro/utils' -export default defineEventHandler(async(event) => { - const rules = await resolveSecurityRules(event) +export default defineEventHandler((event) => { + const rules = resolveSecurityRules(event) if (rules?.corsHandler) { const { corsHandler } = rules diff --git a/src/runtime/server/middleware/rateLimiter.ts b/src/runtime/server/middleware/rateLimiter.ts index 57eadf94..8d96a5a5 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -1,7 +1,7 @@ import type { H3Event } from 'h3' import { defineEventHandler, getRequestHeader, createError, setResponseHeader, useStorage } from '#imports' import type { RateLimiter } from '~/src/module' -import { resolveSecurityRules } from '../../nitro/utils/context' +import { resolveSecurityRules } from '../../nitro/utils' type StorageItem = { value: number, @@ -10,8 +10,8 @@ type StorageItem = { const storage = useStorage('#storage-driver') -export default defineEventHandler(async (event) => { - const rules = await resolveSecurityRules(event) +export default defineEventHandler(async(event) => { + const rules = resolveSecurityRules(event) if (rules?.rateLimiter) { const { rateLimiter } = rules diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index 7a73c402..af9f1e14 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -1,10 +1,10 @@ import { defineEventHandler, getRequestHeader, createError } from '#imports' -import { resolveSecurityRules } from '../../nitro/utils/context' +import { resolveSecurityRules } from '../../nitro/utils' const FILE_UPLOAD_HEADER = 'multipart/form-data' -export default defineEventHandler(async(event) => { - const rules = await resolveSecurityRules(event) +export default defineEventHandler((event) => { + const rules = resolveSecurityRules(event) if (rules?.requestSizeLimiter) { if (['POST', 'PUT', 'DELETE'].includes(event.node.req.method!)) { diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index a3ac053f..ae19864f 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -7,10 +7,10 @@ import { readMultipartFormData, } from '#imports' import { HTTPMethod } from '~/src/module' -import { resolveSecurityRules } from '../../nitro/utils/context' +import { resolveSecurityRules } from '../../nitro/utils' -export default defineEventHandler(async (event) => { - const rules = await resolveSecurityRules(event) +export default defineEventHandler(async(event) => { + const rules = resolveSecurityRules(event) if (rules?.xssValidator) { const filterOpt: IFilterXSSOptions = { diff --git a/src/types/index.ts b/src/types/index.ts index f3fb3b1b..2fd81118 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,7 +46,7 @@ declare module 'nitropack' { headers: SecurityHeaders }) => void 'nuxt-security:ready': () => void - 'nuxt-security:rules': (rules: NuxtSecurityRouteRules) => void + 'nuxt-security:routeRules': (routeRules: Record) => void } } diff --git a/test/fixtures/runtimeHooks/nuxt.config.ts b/test/fixtures/runtimeHooks/nuxt.config.ts index c164132a..e2426ddf 100644 --- a/test/fixtures/runtimeHooks/nuxt.config.ts +++ b/test/fixtures/runtimeHooks/nuxt.config.ts @@ -16,7 +16,7 @@ export default defineNuxtConfig({ crossOriginResourcePolicy: 'cross-origin', contentSecurityPolicy: { 'frame-ancestors': ['*','weird-value.com'], - 'script-src': ["'unsafe-inline'", '*'], + 'script-src': ["'unsafe-inline'", '*', "'nonce-{{nonce}}'"], }, }, } diff --git a/test/fixtures/runtimeHooks/pages/dynamic.vue b/test/fixtures/runtimeHooks/pages/headers-dynamic.vue similarity index 100% rename from test/fixtures/runtimeHooks/pages/dynamic.vue rename to test/fixtures/runtimeHooks/pages/headers-dynamic.vue diff --git a/test/fixtures/runtimeHooks/pages/static.vue b/test/fixtures/runtimeHooks/pages/headers-static.vue similarity index 100% rename from test/fixtures/runtimeHooks/pages/static.vue rename to test/fixtures/runtimeHooks/pages/headers-static.vue diff --git a/test/fixtures/runtimeHooks/pages/rules-dynamic.vue b/test/fixtures/runtimeHooks/pages/rules-dynamic.vue new file mode 100644 index 00000000..b72c6da7 --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/rules-dynamic.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/runtimeHooks/pages/rules-static.vue b/test/fixtures/runtimeHooks/pages/rules-static.vue new file mode 100644 index 00000000..cf247eaa --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/rules-static.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/runtimeHooks/server/api/runtime-headers.ts b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts index 2b2909e1..e5cda6d0 100644 --- a/test/fixtures/runtimeHooks/server/api/runtime-headers.ts +++ b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts @@ -2,8 +2,8 @@ export default defineEventHandler((event) => { const headers = { contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", '*.dynamic-value.com'], + "script-src": ["'self'", '*.dynamic-value.com'], } } - return { headers } + return { headers, nonce: true } }) \ No newline at end of file diff --git a/test/fixtures/runtimeHooks/server/plugins/headers.ts b/test/fixtures/runtimeHooks/server/plugins/headers.ts index 5894e89b..ba434b0f 100644 --- a/test/fixtures/runtimeHooks/server/plugins/headers.ts +++ b/test/fixtures/runtimeHooks/server/plugins/headers.ts @@ -3,10 +3,10 @@ export default defineNitroPlugin((nitroApp) => { // CSP will be set to the static values provided here nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/static', + route: '/headers-static', headers: { contentSecurityPolicy: { - "script-src": ["'self'", "'unsafe-inline'", "static-value.com"], + "script-src": ["'self'", "static-value.com"], } } }) @@ -14,7 +14,7 @@ export default defineNitroPlugin((nitroApp) => { // CSP will be set to the dynamic values fetched from the API const { headers } = await $fetch('/api/runtime-headers') nitroApp.hooks.callHook('nuxt-security:headers', { - route: '/dynamic', + route: '/headers-dynamic', headers }) }) diff --git a/test/fixtures/runtimeHooks/server/plugins/routeRules.ts b/test/fixtures/runtimeHooks/server/plugins/routeRules.ts new file mode 100644 index 00000000..8ab26193 --- /dev/null +++ b/test/fixtures/runtimeHooks/server/plugins/routeRules.ts @@ -0,0 +1,18 @@ +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('nuxt-security:routeRules', async(routeRules) => { + + // CSP will be set to the static values provided here + routeRules['/rules-static'] = { + hidePoweredBy: false, + headers: { + contentSecurityPolicy: { + "script-src": ["'self'", "static-value.com"], + } + } + } + + // CSP will be set to the dynamic values fetched from the API + const options = await $fetch('/api/runtime-headers') + routeRules['/rules-dynamic'] = options + }) +}) \ No newline at end of file diff --git a/test/runtimeHooks.test.ts b/test/runtimeHooks.test.ts index 557d606d..f6e96ccc 100644 --- a/test/runtimeHooks.test.ts +++ b/test/runtimeHooks.test.ts @@ -7,13 +7,30 @@ await setup({ }) describe('[nuxt-security] runtime hooks', () => { - it('expect csp to be set to static values by a runtime hook', async () => { - const res = await fetch('/static') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' 'unsafe-inline' static-value.com; upgrade-insecure-requests;\"") + it('expect csp to be set to static values by the (deprecated) headers runtime hook', async () => { + const res = await fetch('/headers-static') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(res.headers.get('X-Powered-By')).toBeNull() }) + - it('expect csp to be set to dynamically-fetched values by a runtime hook', async () => { - const res = await fetch('/dynamic') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.dynamic-value.com; upgrade-insecure-requests;\"") + it('expect csp to be set to dynamically-fetched values by the (deprecated) headers runtime hook', async () => { + const res = await fetch('/headers-dynamic') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + }) + + it('expect any security option to be modified by the new routeRules runtime hook', async () => { + const res = await fetch('/rules-static') + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(res.headers.get('X-Powered-By')).toEqual('Nuxt') + }) + + it('expect any security option to be dynamically-fetched by the new routeRules runtime hook', async () => { + const res = await fetch('/rules-dynamic') + const csp = res.headers.get('Content-Security-Policy') + expect(csp).not.toBeNull() + expect(csp).toContain("'nonce-") + const strippedCsp = csp!.replace(/ 'nonce-[^']+'/g, "") + expect(strippedCsp).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") }) }) \ No newline at end of file From af17381fa3ebf3c93d7a1faae4e4523c72ff71c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Thu, 18 Apr 2024 17:16:51 +0200 Subject: [PATCH 11/21] Fix playground examples --- playground/pages/runtime.vue | 1 + playground/pages/runtime2.vue | 8 ++++++++ playground/server/api/runtime-headers.ts | 9 +++++---- playground/server/plugins/headers.ts | 2 -- playground/server/plugins/headers2.ts | 7 +++---- 5 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 playground/pages/runtime2.vue diff --git a/playground/pages/runtime.vue b/playground/pages/runtime.vue index 5d26a599..4dbff5eb 100644 --- a/playground/pages/runtime.vue +++ b/playground/pages/runtime.vue @@ -1,6 +1,7 @@ diff --git a/playground/pages/runtime2.vue b/playground/pages/runtime2.vue new file mode 100644 index 00000000..a64f3bf7 --- /dev/null +++ b/playground/pages/runtime2.vue @@ -0,0 +1,8 @@ + + diff --git a/playground/server/api/runtime-headers.ts b/playground/server/api/runtime-headers.ts index d559c5eb..550207ca 100644 --- a/playground/server/api/runtime-headers.ts +++ b/playground/server/api/runtime-headers.ts @@ -1,12 +1,13 @@ export default defineEventHandler((event) => { const time = new Date().toISOString() return { + // The (deprecated) headers hook can modify headers but not the other options headers: { contentSecurityPolicy: { - 'script-src': ["'self'", "'unsafe-inline'", "'nonce-{{nonce}}'", time] + 'script-src': [time] // Time is not a valid CSP value, but it's just an example to verify that it's set once and not re-evaluated - // Nonce is provided in valid placeholder format, and it's set to be replaced with the proper nonce value - }, - } + } + }, + hidePoweredBy: false // The new routeRules hook can modify any option. This will show the server name. } }) \ No newline at end of file diff --git a/playground/server/plugins/headers.ts b/playground/server/plugins/headers.ts index 1ea51ed1..017404a6 100644 --- a/playground/server/plugins/headers.ts +++ b/playground/server/plugins/headers.ts @@ -1,12 +1,10 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:ready', async() => { const { headers } = await $fetch('/api/runtime-headers') - /* nitroApp.hooks.callHook('nuxt-security:headers', { route: '/runtime', headers }) - */ }) }) \ No newline at end of file diff --git a/playground/server/plugins/headers2.ts b/playground/server/plugins/headers2.ts index ff848f1f..e0d01022 100644 --- a/playground/server/plugins/headers2.ts +++ b/playground/server/plugins/headers2.ts @@ -1,8 +1,7 @@ -import defu from "defu" - export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('nuxt-security:routeRules', async routeRules => { - const { headers } = await $fetch('/api/runtime-headers') - routeRules['/'] = { headers, nonce: false } + const options = await $fetch('/api/runtime-headers') + routeRules['/runtime2'] = options + // The server name will be apparent }) }) \ No newline at end of file From efcf9d0127a07437bc25eb2fa1878979ebe8f888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Thu, 18 Apr 2024 20:12:31 +0200 Subject: [PATCH 12/21] All tests now pass --- playground/nuxt.config.ts | 11 +++++++- playground/pages/preserve.vue | 6 +++++ playground/server/middleware/preserve.ts | 6 +++++ src/defaultConfig.ts | 3 ++- src/module.ts | 7 ++--- src/runtime/nitro/plugins/10-nonce.ts | 2 +- src/runtime/nitro/plugins/20-hidePoweredBy.ts | 2 +- .../nitro/plugins/30-securityHeaders.ts | 26 ++++++++++++------- .../nitro/plugins/40-preprocessHtml.ts | 8 +++--- .../nitro/plugins/50-subresourceIntegrity.ts | 5 ++-- src/runtime/nitro/plugins/60-cspSsgHashes.ts | 4 +-- src/runtime/nitro/plugins/70-cspSsgPresets.ts | 1 + src/runtime/nitro/plugins/80-cspSsrNonce.ts | 7 +++-- src/runtime/nitro/plugins/90-recombineHtml.ts | 6 ++--- src/runtime/nitro/utils/index.ts | 4 +-- .../middleware/allowedMethodsRestricter.ts | 2 +- src/runtime/server/middleware/corsHandler.ts | 2 +- src/runtime/server/middleware/rateLimiter.ts | 2 +- .../server/middleware/requestSizeLimiter.ts | 2 +- src/runtime/server/middleware/xssValidator.ts | 2 +- src/types/index.ts | 3 +++ 21 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 playground/pages/preserve.vue create mode 100644 playground/server/middleware/preserve.ts diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 7f93b25f..02eaf48f 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -15,11 +15,20 @@ export default defineNuxtConfig({ } }, headers: { - 'X-XSS-Protection': '1' + 'X-XSS-Protection': '1', + 'Foo': 'Bar' } }, '/about': { prerender: true + }, + '/preserve': { + security: { + headers: { + contentSecurityPolicy: false, + referrerPolicy: false + } + } } }, diff --git a/playground/pages/preserve.vue b/playground/pages/preserve.vue new file mode 100644 index 00000000..22980782 --- /dev/null +++ b/playground/pages/preserve.vue @@ -0,0 +1,6 @@ + diff --git a/playground/server/middleware/preserve.ts b/playground/server/middleware/preserve.ts new file mode 100644 index 00000000..59727240 --- /dev/null +++ b/playground/server/middleware/preserve.ts @@ -0,0 +1,6 @@ +export default defineEventHandler((event) => { + if (event.path.startsWith('/preserve')) { + setResponseHeader(event, 'Content-Security-Policy', 'example') + setResponseHeader(event, 'Referrer-Policy', 'harder-example') + } +}) diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 11294494..3b13fc17 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -88,5 +88,6 @@ export const defaultSecurityConfig = (serverlUrl: string): Partial({ // Register nitro plugin to enable CSP Headers presets for SSG // TEMPORARILY DISABLED AS NUXT 3.9.3 PREVENTS IMPORTING @NUXT/KIT IN NITRO PLUGINS /* - addServerPlugin(resolve('./runtime/nitro/plugins/70-cspSsgPresets')) + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/70-cspSsgPresets')) */ // Nitro plugin to enable CSP Nonce for SSR @@ -162,7 +163,7 @@ export default defineNuxtModule({ await installModule('nuxt-csurf', csrfConfig) } await installModule('nuxt-csurf') - } + } } }) diff --git a/src/runtime/nitro/plugins/10-nonce.ts b/src/runtime/nitro/plugins/10-nonce.ts index d0e159fa..b80e3579 100644 --- a/src/runtime/nitro/plugins/10-nonce.ts +++ b/src/runtime/nitro/plugins/10-nonce.ts @@ -6,7 +6,7 @@ import { resolveSecurityRules } from "../utils" export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('request', (event) => { const rules = resolveSecurityRules(event) - if (rules.nonce) { + if (rules.enabled && rules.nonce) { const nonce = crypto.randomBytes(16).toString('base64') event.context.security.nonce = nonce } diff --git a/src/runtime/nitro/plugins/20-hidePoweredBy.ts b/src/runtime/nitro/plugins/20-hidePoweredBy.ts index 618dca31..4705a361 100644 --- a/src/runtime/nitro/plugins/20-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/20-hidePoweredBy.ts @@ -4,7 +4,7 @@ import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('beforeResponse', (event) => { const rules = resolveSecurityRules(event) - if (rules.hidePoweredBy && !event.node.res.headersSent) { + if (rules.enabled && rules.hidePoweredBy && !event.node.res.headersSent) { removeResponseHeader(event, 'x-powered-by') } }) diff --git a/src/runtime/nitro/plugins/30-securityHeaders.ts b/src/runtime/nitro/plugins/30-securityHeaders.ts index ed0927a1..ef016584 100644 --- a/src/runtime/nitro/plugins/30-securityHeaders.ts +++ b/src/runtime/nitro/plugins/30-securityHeaders.ts @@ -1,4 +1,4 @@ -import { defineNitroPlugin, setResponseHeader, removeResponseHeader } from '#imports' +import { defineNitroPlugin, setResponseHeader, removeResponseHeader, getRouteRules, getResponseHeader } from '#imports' import { ContentSecurityPolicyValue, type OptionKey } from '../../../types/headers' import { getNameFromKey, headerStringFromObject } from '../../utils/headers' import { resolveSecurityRules } from '../utils' @@ -6,18 +6,24 @@ import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:response', (response, { event }) => { const rules = resolveSecurityRules(event) - const headers = { ...rules.headers } - const nonce = event.context.security?.nonce - - if (headers && headers.contentSecurityPolicy) { - const csp = headers.contentSecurityPolicy - headers.contentSecurityPolicy = insertNonceInCsp(csp, nonce) - } - if (headers) { + if (rules.enabled && rules.headers) { + const headers = { ...rules.headers } + const nonce = event.context.security?.nonce + + if (headers.contentSecurityPolicy) { + const csp = headers.contentSecurityPolicy + headers.contentSecurityPolicy = insertNonceInCsp(csp, nonce) + } + Object.entries(headers).forEach(([header, value]) => { const headerName = getNameFromKey(header as OptionKey) if (value === false) { - removeResponseHeader(event, headerName) + const { headers: standardHeaders } = getRouteRules(event) + const standardHeaderValue = standardHeaders?.[headerName] + const currentHeaderValue = getResponseHeader(event, headerName) + if (standardHeaderValue === currentHeaderValue) { + removeResponseHeader(event, headerName) + } } else { const headerValue = headerStringFromObject(header as OptionKey, value) setResponseHeader(event, headerName, headerValue) diff --git a/src/runtime/nitro/plugins/40-preprocessHtml.ts b/src/runtime/nitro/plugins/40-preprocessHtml.ts index 9d6eb93e..e2d44e6f 100644 --- a/src/runtime/nitro/plugins/40-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/40-preprocessHtml.ts @@ -6,13 +6,11 @@ import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (html, { event }) => { - // Exit if no need to parse HTML for this route + // Skip if no need to parse HTML for this route const rules = resolveSecurityRules(event) - // const { rules } = event.context.security - if (!rules?.sri && (!rules?.headers || !rules?.headers.contentSecurityPolicy)) { + if (!rules.enabled || (!rules.sri && (!rules.headers || !rules.headers.contentSecurityPolicy))) { return } - type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] const cheerios = {} as Record[]> @@ -27,6 +25,6 @@ export default defineNitroPlugin((nitroApp) => { }, false) }) } - event.context.cheerios = cheerios + event.context.security.cheerios = cheerios }) }) \ No newline at end of file diff --git a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts index 56072a22..0ab05d31 100644 --- a/src/runtime/nitro/plugins/50-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts @@ -8,8 +8,7 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', async(html, { event }) => { // Exit if SRI not enabled for this route const rules = resolveSecurityRules(event) - // const { rules } = event.context.security - if (!rules?.sri) { + if (!rules.enabled || !rules.sri) { return } @@ -32,7 +31,7 @@ export default defineNitroPlugin((nitroApp) => { // However the SRI standard provides that other elements may be added to that list in the future type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] - const cheerios = event.context.cheerios as Record + const cheerios = event.context.security.cheerios! for (const section of sections) { cheerios[section].forEach($ => { // Add integrity to all relevant script tags diff --git a/src/runtime/nitro/plugins/60-cspSsgHashes.ts b/src/runtime/nitro/plugins/60-cspSsgHashes.ts index 145369ff..df4bfcff 100644 --- a/src/runtime/nitro/plugins/60-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/60-cspSsgHashes.ts @@ -16,7 +16,7 @@ export default defineNitroPlugin((nitroApp) => { // Exit if no CSP defined const rules = resolveSecurityRules(event) - if (!rules?.headers || !rules.headers.contentSecurityPolicy) { + if (!rules.enabled || !rules.headers || !rules.headers.contentSecurityPolicy) { return } @@ -24,7 +24,7 @@ export default defineNitroPlugin((nitroApp) => { const styleHashes: Set = new Set() const hashAlgorithm = 'sha256' type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' - const cheerios = event.context.cheerios as Record[]> + const cheerios = event.context.security.cheerios! // Parse HTML if SSG is enabled for this route if (rules.ssg) { diff --git a/src/runtime/nitro/plugins/70-cspSsgPresets.ts b/src/runtime/nitro/plugins/70-cspSsgPresets.ts index 420f1e2e..f4ccbec9 100644 --- a/src/runtime/nitro/plugins/70-cspSsgPresets.ts +++ b/src/runtime/nitro/plugins/70-cspSsgPresets.ts @@ -3,6 +3,7 @@ import { tryUseNuxt, useNitro } from '@nuxt/kit' import { defu } from 'defu' + export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (_, { event }) => { // Exit if Nitro not available diff --git a/src/runtime/nitro/plugins/80-cspSsrNonce.ts b/src/runtime/nitro/plugins/80-cspSsrNonce.ts index ac2b3d83..3e407cb3 100644 --- a/src/runtime/nitro/plugins/80-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/80-cspSsrNonce.ts @@ -13,18 +13,17 @@ export default defineNitroPlugin((nitroApp) => { // Exit if no CSP defined const rules = resolveSecurityRules(event) - // const { rules } = event.context.security - if (!rules?.headers || !rules.headers.contentSecurityPolicy) { + if (!rules.enabled || !rules.headers || !rules.headers.contentSecurityPolicy) { return } // Parse HTML if nonce is enabled for this route if (rules.nonce) { - const nonce = event.context.security.nonce + const nonce = event.context.security.nonce! // Scan all relevant sections of the NuxtRenderHtmlContext type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] - const cheerios = event.context.cheerios as Record + const cheerios = event.context.security.cheerios! for (const section of sections) { cheerios[section].forEach($ => { // Add nonce to all link tags diff --git a/src/runtime/nitro/plugins/90-recombineHtml.ts b/src/runtime/nitro/plugins/90-recombineHtml.ts index 6cc058c0..4dd7e985 100644 --- a/src/runtime/nitro/plugins/90-recombineHtml.ts +++ b/src/runtime/nitro/plugins/90-recombineHtml.ts @@ -1,5 +1,4 @@ import { defineNitroPlugin } from '#imports' -import { type CheerioAPI } from 'cheerio' import { resolveSecurityRules } from '../utils' @@ -8,15 +7,14 @@ export default defineNitroPlugin((nitroApp) => { // Exit if no need to parse HTML for this route const rules = resolveSecurityRules(event) - // const { rules } = event.context.security - if (!rules?.sri && (!rules?.headers || !rules.headers.contentSecurityPolicy)) { + if (!rules.enabled || (!rules.sri && (!rules.headers || !rules.headers.contentSecurityPolicy))) { return } // Scan all relevant sections of the NuxtRenderHtmlContext type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[] - const cheerios = event.context.cheerios as Record + const cheerios = event.context.security.cheerios! for (const section of sections) { html[section] = cheerios[section].map($ => { const html = $.html() diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index c8fb3851..de96785c 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -1,5 +1,5 @@ -import type { NuxtSecurityRouteRules } from "../../../types" +import type { ModuleOptions, NuxtSecurityRouteRules } from "../../../types" import { createRouter, toRouteMatcher } from "radix3" import type { H3Event } from "h3" import defu from "defu" @@ -9,6 +9,6 @@ export function resolveSecurityRules(event: H3Event) { const router = createRouter({ routes: routeRules}) const matcher = toRouteMatcher(router) const matches = matcher.matchAll(event.path.split('?')[0]) - const rules: NuxtSecurityRouteRules = defu({}, ...matches.reverse()) + const rules = defu({}, ...matches.reverse()) as ModuleOptions return rules } \ No newline at end of file diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index 001f7867..b19b333a 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -5,7 +5,7 @@ import { resolveSecurityRules } from '../../nitro/utils' export default defineEventHandler((event) => { const rules = resolveSecurityRules(event) - if (rules?.allowedMethodsRestricter) { + if (rules.enabled && rules.allowedMethodsRestricter) { const { allowedMethodsRestricter } = rules const allowedMethods = allowedMethodsRestricter.methods diff --git a/src/runtime/server/middleware/corsHandler.ts b/src/runtime/server/middleware/corsHandler.ts index 2bcd8536..598a7d9c 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -5,7 +5,7 @@ import { resolveSecurityRules } from '../../nitro/utils' export default defineEventHandler((event) => { const rules = resolveSecurityRules(event) - if (rules?.corsHandler) { + if (rules.enabled && rules.corsHandler) { const { corsHandler } = rules handleCors(event, corsHandler as H3CorsOptions) } diff --git a/src/runtime/server/middleware/rateLimiter.ts b/src/runtime/server/middleware/rateLimiter.ts index 8d96a5a5..61e900a4 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -13,7 +13,7 @@ const storage = useStorage('#storage-driver') export default defineEventHandler(async(event) => { const rules = resolveSecurityRules(event) - if (rules?.rateLimiter) { + if (rules.enabled && rules.rateLimiter) { const { rateLimiter } = rules const ip = getIP(event) diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index af9f1e14..8778b9b4 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -6,7 +6,7 @@ const FILE_UPLOAD_HEADER = 'multipart/form-data' export default defineEventHandler((event) => { const rules = resolveSecurityRules(event) - if (rules?.requestSizeLimiter) { + if (rules.enabled && rules.requestSizeLimiter) { if (['POST', 'PUT', 'DELETE'].includes(event.node.req.method!)) { const contentLengthValue = getRequestHeader(event, 'content-length') const contentTypeValue = getRequestHeader(event, 'content-type') diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index ae19864f..b28e7504 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -12,7 +12,7 @@ import { resolveSecurityRules } from '../../nitro/utils' export default defineEventHandler(async(event) => { const rules = resolveSecurityRules(event) - if (rules?.xssValidator) { + if (rules.enabled && rules.xssValidator) { const filterOpt: IFilterXSSOptions = { ...rules.xssValidator, escapeHtml: undefined diff --git a/src/types/index.ts b/src/types/index.ts index 2fd81118..c9d8bb98 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ import type { ModuleOptions as CsrfOptions } from 'nuxt-csurf' import type { Options as RemoveOptions } from 'unplugin-remove/types' import type { SecurityHeaders } from './headers' import type { AllowedHTTPMethods, BasicAuth, RateLimiter, RequestSizeLimiter, XssValidator, CorsOptions } from './middlewares' +import type { CheerioAPI } from 'cheerio' export type Ssg = { meta?: boolean; @@ -50,11 +51,13 @@ declare module 'nitropack' { } } +type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' declare module 'h3' { interface H3EventContext { security: { routeRules?: Record; nonce?: string; + cheerios?: Record; } } } \ No newline at end of file From d24652023930d08aab10a889e89b17e8f14fa278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Thu, 18 Apr 2024 20:16:21 +0200 Subject: [PATCH 13/21] clean lint errors --- playground/app.vue | 2 +- playground/server/api/runtime-headers.ts | 2 +- src/module.ts | 8 +------- src/runtime/nitro/plugins/50-subresourceIntegrity.ts | 2 -- src/runtime/nitro/plugins/80-cspSsrNonce.ts | 2 -- test/fixtures/runtimeHooks/server/api/runtime-headers.ts | 2 +- 6 files changed, 4 insertions(+), 14 deletions(-) diff --git a/playground/app.vue b/playground/app.vue index bc136fed..5418ee52 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -5,7 +5,7 @@ diff --git a/playground/server/api/test.post.ts b/playground/server/api/test.post.ts index 272797da..621f919e 100644 --- a/playground/server/api/test.post.ts +++ b/playground/server/api/test.post.ts @@ -1,3 +1,5 @@ export default defineEventHandler(async (event) => { console.log('api test', event.path) + const time = new Date().toISOString() + return time }) diff --git a/src/runtime/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index de96785c..1a8dfd27 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -2,13 +2,13 @@ import type { ModuleOptions, NuxtSecurityRouteRules } from "../../../types" import { createRouter, toRouteMatcher } from "radix3" import type { H3Event } from "h3" -import defu from "defu" +import { defuReplaceArray } from "../../../../src/utils" export function resolveSecurityRules(event: H3Event) { const routeRules = event.context.security?.routeRules const router = createRouter({ routes: routeRules}) const matcher = toRouteMatcher(router) const matches = matcher.matchAll(event.path.split('?')[0]) - const rules = defu({}, ...matches.reverse()) as ModuleOptions + const rules = defuReplaceArray({}, ...matches.reverse()) as ModuleOptions return rules } \ No newline at end of file diff --git a/test/fixtures/runtimeHooks/server/api/runtime-headers.ts b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts index b3103da9..c2eea4b1 100644 --- a/test/fixtures/runtimeHooks/server/api/runtime-headers.ts +++ b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts @@ -2,8 +2,8 @@ export default defineEventHandler(() => { const headers = { contentSecurityPolicy: { - "script-src": ["'self'", '*.dynamic-value.com'], + "script-src": ["'self'", '*.dynamic-value.com', "'nonce-{{nonce}}'"], } } - return { headers, nonce: true } + return { headers, hidePoweredBy: false } }) \ No newline at end of file diff --git a/test/perRoute.test.ts b/test/perRoute.test.ts index 59e26da8..32ea1f44 100644 --- a/test/perRoute.test.ts +++ b/test/perRoute.test.ts @@ -568,7 +568,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { expect(corp).toBeNull() expect(coop).toBe('same-origin') expect(coep).toBe('require-corp') - expect(csp).toBe("font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src https: 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self'; upgrade-insecure-requests;") + expect(csp).toBe("font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src https:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self'; upgrade-insecure-requests;") expect(oac).toBe('?1') expect(rp).toBe('no-referrer') expect(sts).toBe('max-age=10; preload;') @@ -633,6 +633,8 @@ describe('[nuxt-security] Per-route Configuration', async () => { expect(coep).toBeNull() }) + // DEPRECATED + /* it('supports concatenation merging via the array syntax', async () => { const { headers } = await fetch('/merge-concatenate-array/deep/page') expect(headers).toBeDefined() @@ -643,6 +645,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { expect(csp).toBe("base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src blob: https: 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' https: 'unsafe-inline' 'strict-dynamic'; upgrade-insecure-requests;") expect(pp).toBe('camera=(), display-capture=(https://* self), fullscreen=(), geolocation=(), microphone=()') }) + */ it('supports substitution merging via the string syntax', async () => { const { headers } = await fetch('/merge-substitute-string/deep/page') diff --git a/test/runtimeHooks.test.ts b/test/runtimeHooks.test.ts index f6e96ccc..da0bec1e 100644 --- a/test/runtimeHooks.test.ts +++ b/test/runtimeHooks.test.ts @@ -9,28 +9,27 @@ await setup({ describe('[nuxt-security] runtime hooks', () => { it('expect csp to be set to static values by the (deprecated) headers runtime hook', async () => { const res = await fetch('/headers-static') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com; upgrade-insecure-requests;\"") expect(res.headers.get('X-Powered-By')).toBeNull() }) it('expect csp to be set to dynamically-fetched values by the (deprecated) headers runtime hook', async () => { const res = await fetch('/headers-dynamic') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com; upgrade-insecure-requests;\"") + expect(res.headers.get('X-Powered-By')).toBeNull() }) it('expect any security option to be modified by the new routeRules runtime hook', async () => { const res = await fetch('/rules-static') - expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(res.headers.get('Content-Security-Policy')).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' static-value.com; upgrade-insecure-requests;\"") expect(res.headers.get('X-Powered-By')).toEqual('Nuxt') }) it('expect any security option to be dynamically-fetched by the new routeRules runtime hook', async () => { const res = await fetch('/rules-dynamic') const csp = res.headers.get('Content-Security-Policy') - expect(csp).not.toBeNull() - expect(csp).toContain("'nonce-") - const strippedCsp = csp!.replace(/ 'nonce-[^']+'/g, "") - expect(strippedCsp).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com 'unsafe-inline' *; upgrade-insecure-requests;\"") + expect(csp).toMatchInlineSnapshot("\"base-uri 'none'; font-src 'self' https: data:; form-action 'self'; frame-ancestors * weird-value.com; img-src 'self' data:; object-src 'none'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; script-src 'self' *.dynamic-value.com; upgrade-insecure-requests;\"") + expect(res.headers.get('X-Powered-By')).toEqual('Nuxt') }) }) \ No newline at end of file From 0fe4d5ce8e32be105de58607234031884dbf974a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Fri, 19 Apr 2024 16:58:33 +0200 Subject: [PATCH 15/21] documentation update --- docs/components/content/Illustration.vue | 34 +++++- docs/components/content/Releases.vue | 9 +- .../1.getting-started/3.usage.md | 68 +++++++---- .../1.documentation/2.headers/1.csp.md | 63 +++++------ playground/nuxt.config.ts | 6 +- playground/pages/about.vue | 5 +- playground/pages/swr.vue | 21 ++++ src/runtime/nitro/plugins/00-routeRules.ts | 106 ++++++++++-------- src/runtime/nitro/utils/index.ts | 4 +- src/types/index.ts | 40 +++++-- .../hidePoweredBy/server/plugins/error.ts | 2 +- test/fixtures/sri/pages/index.vue | 4 +- test/hidePoweredBy.test.ts | 2 +- test/rateLimiter.test.ts | 12 +- test/ssgHashes.test.ts | 1 - 15 files changed, 241 insertions(+), 136 deletions(-) create mode 100644 playground/pages/swr.vue diff --git a/docs/components/content/Illustration.vue b/docs/components/content/Illustration.vue index 29558cc2..46802cdc 100644 --- a/docs/components/content/Illustration.vue +++ b/docs/components/content/Illustration.vue @@ -227,7 +227,10 @@ filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB" > - + - + - + - + - + - + diff --git a/docs/components/content/Releases.vue b/docs/components/content/Releases.vue index 47b60321..da4e9ed2 100644 --- a/docs/components/content/Releases.vue +++ b/docs/components/content/Releases.vue @@ -1,10 +1,13 @@