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 @@ 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/pages/runtime.vue b/playground/pages/runtime.vue new file mode 100644 index 00000000..4dbff5eb --- /dev/null +++ b/playground/pages/runtime.vue @@ -0,0 +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/pages/swr.vue b/playground/pages/swr.vue new file mode 100644 index 00000000..db18362e --- /dev/null +++ b/playground/pages/swr.vue @@ -0,0 +1,21 @@ + + diff --git a/playground/server/api/runtime-headers.ts b/playground/server/api/runtime-headers.ts new file mode 100644 index 00000000..bdd11f26 --- /dev/null +++ b/playground/server/api/runtime-headers.ts @@ -0,0 +1,13 @@ +export default defineEventHandler(() => { + const time = new Date().toISOString() + return { + // The (deprecated) headers hook can modify headers but not the other options + headers: { + contentSecurityPolicy: { + '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 + } + }, + 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/api/runtime-hooks.ts b/playground/server/api/runtime-hooks.ts deleted file mode 100644 index e7d03e25..00000000 --- a/playground/server/api/runtime-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineEventHandler } from "#imports" - -export default defineEventHandler((event) => { - return { - csp: getResponseHeader(event, 'Content-Security-Policy') - } -}) \ No newline at end of file diff --git a/playground/server/api/test.post.ts b/playground/server/api/test.post.ts index 4f231748..621f919e 100644 --- a/playground/server/api/test.post.ts +++ b/playground/server/api/test.post.ts @@ -1,3 +1,5 @@ -export default defineEventHandler((event) => { - console.log('test') +export default defineEventHandler(async (event) => { + console.log('api test', event.path) + const time = new Date().toISOString() + return time }) 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/playground/server/plugins/headers.ts b/playground/server/plugins/headers.ts index a2cc10d4..017404a6 100644 --- a/playground/server/plugins/headers.ts +++ b/playground/server/plugins/headers.ts @@ -1,14 +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/playground/server/plugins/headers2.ts b/playground/server/plugins/headers2.ts new file mode 100644 index 00000000..e0d01022 --- /dev/null +++ b/playground/server/plugins/headers2.ts @@ -0,0 +1,7 @@ +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('nuxt-security:routeRules', async routeRules => { + const options = await $fetch('/api/runtime-headers') + routeRules['/runtime2'] = options + // The server name will be apparent + }) +}) \ No newline at end of file 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; + security?: NuxtSecurityRouteRules; + } + interface NitroRuntimeHooks { + /** + * @deprecated + */ + 'nuxt-security:headers': (config: { + /** + * The route for which the headers are being configured + */ + route: string, + /** + * The headers configuration for the route + */ + headers: NuxtSecurityRouteRules['headers'] + }) => void + /** + * @deprecated + */ + 'nuxt-security:ready': () => void + /** + * Runtime hook to configure security rules for each route + */ + 'nuxt-security:routeRules': (routeRules: Record) => void + } +} + +type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head' +declare module 'h3' { + interface H3EventContext { + security: { + routeRules?: Record; + nonce?: string; + cheerios?: Record; + } } } @@ -48,324 +69,169 @@ 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) + + nuxt.options.build.transpile.push(resolver.resolve('./runtime')) + + // First merge module options with default options nuxt.options.security = defuReplaceArray( { ...options, ...nuxt.options.security }, { ...defaultSecurityConfig(nuxt.options.devServer.url) } ) - const securityOptions = nuxt.options.security - // Disabled module when `enabled` is set to `false` - if (!securityOptions.enabled) { return } - - if (securityOptions.removeLoggers) { - addVitePlugin(viteRemove(securityOptions.removeLoggers)) - } - - registerSecurityNitroPlugins(nuxt, securityOptions) + // Then transfer basicAuth to private runtimeConfig nuxt.options.runtimeConfig.private = defu( nuxt.options.runtimeConfig.private, { - basicAuth: securityOptions.basicAuth + basicAuth: nuxt.options.security.basicAuth } ) + delete (nuxt.options.security as any).basicAuth - delete (securityOptions as any).basicAuth - + // Lastly, merge runtimeConfig with module options nuxt.options.runtimeConfig.security = defu( nuxt.options.runtimeConfig.security, { - ...securityOptions + ...nuxt.options.security } ) + // At this point we have all security options merged into runtimeConfig + const securityOptions = nuxt.options.runtimeConfig.security - // PER ROUTE OPTIONS - setGlobalSecurityRoute(nuxt, securityOptions) - mergeSecurityPerRoute(nuxt) - - addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/cspNonceHandler') - ) - }) + // Disable module when `enabled` is set to `false` + if (!securityOptions.enabled) { return } - if (nuxt.options.security.requestSizeLimiter) { - addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/requestSizeLimiter') - ) - }) + // Register Vite transform plugin to remove loggers + if (securityOptions.removeLoggers) { + addVitePlugin(viteRemove(securityOptions.removeLoggers)) } + + // Register nitro plugin to manage security rules at the level of each route + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules')) - if (nuxt.options.security.rateLimiter) { - addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/rateLimiter') - ) - }) - } + // Register nitro plugin to add nonce + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/10-nonce')) - if (nuxt.options.security.xssValidator) { - // 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') - ) - }) - } + // Register nitro plugin to hide X-Powered-By header + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/20-hidePoweredBy')) - if (nuxt.options.security.corsHandler) { - addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/corsHandler') - ) - }) - } - - - if(nuxt.options.security.runtimeHooks) { - addServerPlugin(resolve(runtimeDir, 'nitro/plugins/00-context')) - } + // Register nitro plugin to enable Security Headers + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/30-securityHeaders')) - const allowedMethodsRestricterConfig = nuxt.options.security - .allowedMethodsRestricter - if ( - allowedMethodsRestricterConfig && - !Object.values(allowedMethodsRestricterConfig).includes('*') - ) { - addServerHandler({ - handler: normalize( - resolve(runtimeDir, 'server/middleware/allowedMethodsRestricter') - ) - }) - } + // Pre-process HTML into DOM tree + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/40-preprocessHtml')) - // Register basicAuth middleware that is disabled by default - const basicAuthConfig = nuxt.options.runtimeConfig.private - .basicAuth as unknown as BasicAuth - if (basicAuthConfig && ((basicAuthConfig as any)?.enabled || (basicAuthConfig as any)?.value?.enabled)) { - addServerHandler({ - route: (basicAuthConfig as any).route || '', - handler: normalize(resolve(runtimeDir, 'server/middleware/basicAuth')) - }) - } - - // Calculates SRI hashes at build time - nuxt.hook('nitro:build:before', hashBundledAssets) + // Register nitro plugin to enable Subresource Integrity + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/50-subresourceIntegrity')) + // Register nitro plugin to enable CSP Hashes for SSG + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/60-cspSsgHashes')) - nuxt.hook('imports:dirs', (dirs) => { - dirs.push(normalize(resolve(runtimeDir, 'composables'))) - }) + // 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(resolver.resolve('./runtime/nitro/plugins/70-cspSsgPresets')) + */ - const csrfConfig = nuxt.options.security.csrf - if (csrfConfig) { - if (Object.keys(csrfConfig).length) { - await installModule('nuxt-csurf', csrfConfig) - } - await installModule('nuxt-csurf') - } - } -}) + // Nitro plugin to enable CSP Nonce for SSR + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/80-cspSsrNonce')) -// 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 - ) -} + // Recombine HTML from DOM tree + addServerPlugin(resolver.resolve('./runtime/nitro/plugins/90-recombineHtml')) -// 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) - } - } - }) - } + // Register hook that will reorder nitro plugins to be applied last + reorderNitroPlugins(nuxt) - // 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 - } - }) - } + // Register request size limiter middleware + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/requestSizeLimiter') + }) - // 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 - ) - } - } -} + // Register CORS middleware + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/corsHandler') + }) + // Register allowed methods restricter middleware + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/allowedMethodsRestricter') + }) -function registerSecurityNitroPlugins(nuxt: Nuxt, securityOptions: ModuleOptions) { - nuxt.hook('nitro:config', (config) => { - config.plugins = config.plugins || [] - - if (securityOptions.rateLimiter) { - // setup unstorage - const driver = (securityOptions.rateLimiter).driver - if (driver) { - const { name, options } = driver - config.storage = defu( - config.storage, - { - '#storage-driver': { - driver: name, - options - } - } - ) - } + // Register rate limiter middleware + registerRateLimiterStorage(nuxt, securityOptions) + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/rateLimiter') + }) + + // Register XSS validator middleware + addServerHandler({ + handler: resolver.resolve('./runtime/server/middleware/xssValidator') + }) + + // Register basicAuth middleware that is disabled by default + const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth + if (basicAuthConfig && (basicAuthConfig.enabled || (basicAuthConfig as any)?.value?.enabled)) { + addServerHandler({ + route: (basicAuthConfig as any).route || '', + handler: resolver.resolve('./runtime/server/middleware/basicAuth') + }) } - // 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) - ) - ) - ) + // Import CSURF module + if (securityOptions.csrf) { + if (Object.keys(securityOptions.csrf).length) { + await installModule('nuxt-csurf', securityOptions.csrf) + } else { + await installModule('nuxt-csurf') + } } - // 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) - ) - ) - ) + // Import server utils + addServerImportsDir(resolver.resolve('./runtime/server/utils')) - // Register nitro plugin to enable CSP Hashes for SSG - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/04-cspSsgHashes', import.meta.url) - ) - ) - ) + // Import composables + addImportsDir(resolver.resolve('./runtime/composables')) - // 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) - ) - ) - ) - */ + // Calculates SRI hashes at build time + nuxt.hook('nitro:build:before', hashBundledAssets) + } +}) - // Nitro plugin to enable CSP Nonce for SSR - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/99-cspSsrNonce', import.meta.url) - ) - ) +/** + * + * Register storage driver for the rate limiter + */ +function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) { + nuxt.hook('nitro:config', (config) => { + const driver = defu( + securityOptions.rateLimiter ? securityOptions.rateLimiter.driver : undefined, + { name: 'lruCache' } ) - - - // Recombine HTML from DOM tree - config.plugins.push( - normalize( - fileURLToPath( - new URL('./runtime/nitro/plugins/99b-recombineHtml', import.meta.url) - ) - ) + const { name, options } = driver + config.storage = defu( + config.storage, + { + '#rate-limiter-storage': { + driver: name, + options + } + } ) }) +} + +/** + * Make sure our nitro plugins will be applied last, + * After all other third-party modules that might have loaded their own nitro plugins + */ +function reorderNitroPlugins(nuxt: Nuxt) { + nuxt.hook('nitro:init', nitro => { + const resolver = createResolver(import.meta.url) + const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') - // 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) - ) - ) // SSR: Reorder plugins in Nitro options nitro.options.plugins.sort((a, b) => { if (a.startsWith(securityPluginsPrefix)) { diff --git a/src/runtime/composables/nonce.ts b/src/runtime/composables/nonce.ts index 7963917e..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.nonce as string + return useNuxtApp().ssrContext?.event?.context.security.nonce } diff --git a/src/runtime/nitro/plugins/00-context.ts b/src/runtime/nitro/plugins/00-context.ts deleted file mode 100644 index 098906cc..00000000 --- a/src/runtime/nitro/plugins/00-context.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getNameFromKey, headerStringFromObject} from "../../utils/headers" -import { createRouter} from "radix3" -import { defineNitroPlugin, setHeader, removeResponseHeader} from "#imports" -import { OptionKey } from "~/src/module" - -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] - } - } - } - - router.insert(route, headers) - }) - - 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 - } - }) - - 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) - } - }) - } - }) - - nitroApp.hooks.callHook('nuxt-security:ready') -}) diff --git a/src/runtime/nitro/plugins/00-routeRules.ts b/src/runtime/nitro/plugins/00-routeRules.ts new file mode 100644 index 00000000..fe372a80 --- /dev/null +++ b/src/runtime/nitro/plugins/00-routeRules.ts @@ -0,0 +1,122 @@ +import { defineNitroPlugin, useRuntimeConfig } from "#imports" +import { NuxtSecurityRouteRules } from "../../../types" +import { defuReplaceArray } from "../../../utils" +import { OptionKey, SecurityHeaders } from "../../../types/headers" +import { getKeyFromName, headerObjectFromString } from "../../utils/headers" + +export default defineNitroPlugin((nitroApp) => { + + const runtimeConfig = useRuntimeConfig() + const securityRouteRules: Record = {} + + // First insert standard route rules headers + for (const route in runtimeConfig.nitro.routeRules) { + const rule = runtimeConfig.nitro.routeRules[route] + const { headers } = rule + const securityHeaders = standardToSecurity(headers) + if (securityHeaders) { + securityRouteRules[route] = { headers: securityHeaders } + } + } + + + // Then insert global security config + const securityOptions = runtimeConfig.security + securityRouteRules['/**'] = defuReplaceArray( + securityOptions, + securityRouteRules['/**'] + ) + + // Then insert route specific security headers + for (const route in runtimeConfig.nitro.routeRules) { + const rule = runtimeConfig.nitro.routeRules[route] + const { security } = rule + if (security) { + const { headers } = security + const securityHeaders = backwardsCompatibleSecurity(headers) + securityRouteRules[route] = defuReplaceArray( + { headers: securityHeaders }, + security, + securityRouteRules[route], + ) + } + } + + // 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', async(event) => { + event.context.security = { routeRules: securityRouteRules } + }) +}) + +/** + * Convert standard headers string format to security headers object format, returning undefined if no valid security header is found + */ +function standardToSecurity(standardHeaders?: Record) { + if (!standardHeaders) { + return undefined + } + + const standardHeadersAsObject: SecurityHeaders = {} + + 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) + } + } + }) + + if (Object.keys(standardHeadersAsObject).length === 0) { + return undefined + } + + return standardHeadersAsObject +} + +/** + * + * Ensure backwards compatibility with pre-rc1 syntax, returning undefined if no securityHeaders is passed + */ +function backwardsCompatibleSecurity(securityHeaders?: SecurityHeaders | false) { + + if (!securityHeaders) { + return undefined + } + + const securityHeadersAsObject: 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 +} diff --git a/src/runtime/nitro/plugins/02-securityHeaders.ts b/src/runtime/nitro/plugins/02-securityHeaders.ts deleted file mode 100644 index b41807a8..00000000 --- a/src/runtime/nitro/plugins/02-securityHeaders.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getRouteRules, defineNitroPlugin, setResponseHeader, getResponseHeader, removeResponseHeader } from '#imports' -import { 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 } = security - 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) - setResponseHeader(event, headerName, headerValue) - } - }) - } - }) -}) \ 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..b80e3579 --- /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 "../utils" + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + const rules = resolveSecurityRules(event) + if (rules.enabled && 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 57% rename from src/runtime/nitro/plugins/01-hidePoweredBy.ts rename to src/runtime/nitro/plugins/20-hidePoweredBy.ts index 13815f21..4705a361 100644 --- a/src/runtime/nitro/plugins/01-hidePoweredBy.ts +++ b/src/runtime/nitro/plugins/20-hidePoweredBy.ts @@ -1,8 +1,10 @@ import { defineNitroPlugin, removeResponseHeader } from '#imports' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('beforeResponse', (event) => { - if (!event.node.res.headersSent) { + const rules = resolveSecurityRules(event) + 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 new file mode 100644 index 00000000..ef016584 --- /dev/null +++ b/src/runtime/nitro/plugins/30-securityHeaders.ts @@ -0,0 +1,59 @@ +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' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('render:response', (response, { event }) => { + const rules = resolveSecurityRules(event) + 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) { + 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) + } + }) + } + }) + +}) + +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/plugins/02a-preprocessHtml.ts b/src/runtime/nitro/plugins/40-preprocessHtml.ts similarity index 62% rename from src/runtime/nitro/plugins/02a-preprocessHtml.ts rename to src/runtime/nitro/plugins/40-preprocessHtml.ts index beba00e5..e2d44e6f 100644 --- a/src/runtime/nitro/plugins/02a-preprocessHtml.ts +++ b/src/runtime/nitro/plugins/40-preprocessHtml.ts @@ -1,16 +1,16 @@ -import { defineNitroPlugin, getRouteRules } from '#imports' +import { defineNitroPlugin } from '#imports' import * as cheerio from 'cheerio/lib/slim' +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 { security } = getRouteRules(event) - if (!security?.sri && (!security?.headers || !security?.headers.contentSecurityPolicy)) { + // Skip if no need to parse HTML for this route + const rules = resolveSecurityRules(event) + 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[]> @@ -25,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/03-subresourceIntegrity.ts b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts similarity index 87% rename from src/runtime/nitro/plugins/03-subresourceIntegrity.ts rename to src/runtime/nitro/plugins/50-subresourceIntegrity.ts index e431a6c7..d33dbb92 100644 --- a/src/runtime/nitro/plugins/03-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/50-subresourceIntegrity.ts @@ -1,13 +1,12 @@ -import { useStorage, defineNitroPlugin, getRouteRules } from '#imports' -import { isPrerendering } from '../utils' -import { type CheerioAPI } from 'cheerio' +import { useStorage, defineNitroPlugin } from '#imports' +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 { security } = getRouteRules(event) - if (!security?.sri) { + const rules = resolveSecurityRules(event) + if (!rules.enabled || !rules.sri) { return } @@ -20,7 +19,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') || {} @@ -30,7 +29,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/04-cspSsgHashes.ts b/src/runtime/nitro/plugins/60-cspSsgHashes.ts similarity index 88% rename from src/runtime/nitro/plugins/04-cspSsgHashes.ts rename to src/runtime/nitro/plugins/60-cspSsgHashes.ts index 7ecf8b00..0b1afc55 100644 --- a/src/runtime/nitro/plugins/04-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/60-cspSsgHashes.ts @@ -1,21 +1,22 @@ -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 { isPrerendering } from '../utils' +import { resolveSecurityRules } from '../utils' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('render:html', (html, { event }) => { // Exit in SSR mode - if (!isPrerendering(event)) { + if (!import.meta.prerender) { return } // Exit if no CSP defined - const { security } = getRouteRules(event) - if (!security?.headers || !security.headers.contentSecurityPolicy) { + const rules = resolveSecurityRules(event) + if (!rules.enabled || !rules.headers || !rules.headers.contentSecurityPolicy) { return } @@ -23,11 +24,11 @@ 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 (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,12 +100,12 @@ 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) { - cheerios.head.push(cheerio.load(``, null, false)) + if (rules.ssg && rules.ssg.meta) { + cheerios.head.unshift(cheerio.load(``, null, false)) } // Update rules in HTTP header setResponseHeader(event, 'Content-Security-Policy', headerValue) diff --git a/src/runtime/nitro/plugins/05-cspSsgPresets.ts b/src/runtime/nitro/plugins/70-cspSsgPresets.ts similarity index 91% rename from src/runtime/nitro/plugins/05-cspSsgPresets.ts rename to src/runtime/nitro/plugins/70-cspSsgPresets.ts index 3ef6e50a..f4ccbec9 100644 --- a/src/runtime/nitro/plugins/05-cspSsgPresets.ts +++ b/src/runtime/nitro/plugins/70-cspSsgPresets.ts @@ -1,7 +1,7 @@ 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 +11,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 new file mode 100644 index 00000000..f1bff092 --- /dev/null +++ b/src/runtime/nitro/plugins/80-cspSsrNonce.ts @@ -0,0 +1,35 @@ +import { defineNitroPlugin } from '#imports' +import { resolveSecurityRules } from '../utils' + + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { + // Exit in SSG mode + if (import.meta.prerender) { + return + } + + // Exit if no CSP defined + const rules = resolveSecurityRules(event) + if (!rules.enabled || !rules.headers || !rules.headers.contentSecurityPolicy || !rules.nonce) { + return + } + + + 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.security.cheerios! + for (const section of sections) { + cheerios[section].forEach($ => { + // Add nonce to all link tags + $('link').attr('nonce', nonce) + // Add nonce to all script tags + $('script').attr('nonce', nonce) + // Add nonce to all style tags + $('style').attr('nonce', nonce) + }) + } + }) +}) diff --git a/src/runtime/nitro/plugins/99b-recombineHtml.ts b/src/runtime/nitro/plugins/90-recombineHtml.ts similarity index 63% rename from src/runtime/nitro/plugins/99b-recombineHtml.ts rename to src/runtime/nitro/plugins/90-recombineHtml.ts index 32136ab3..4dd7e985 100644 --- a/src/runtime/nitro/plugins/99b-recombineHtml.ts +++ b/src/runtime/nitro/plugins/90-recombineHtml.ts @@ -1,20 +1,20 @@ -import { defineNitroPlugin, getRouteRules } from '#imports' -import { type CheerioAPI } from 'cheerio' +import { defineNitroPlugin } from '#imports' +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 - const { security } = getRouteRules(event) - if (!security?.sri && (!security?.headers || !security.headers.contentSecurityPolicy)) { + const rules = resolveSecurityRules(event) + 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/plugins/99-cspSsrNonce.ts b/src/runtime/nitro/plugins/99-cspSsrNonce.ts deleted file mode 100644 index 7f4af4f1..00000000 --- a/src/runtime/nitro/plugins/99-cspSsrNonce.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 }) => { - // Exit in SSG mode - if (isPrerendering(event)) { - return - } - - // Exit if no CSP defined - const { security } = getRouteRules(event) - if (!security?.headers || !security.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 - // 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 - for (const section of sections) { - cheerios[section].forEach($ => { - // Add nonce to all link tags - $('link').attr('nonce', nonce) - // Add nonce to all script tags - $('script').attr('nonce', nonce) - // Add nonce to all style tags - $('style').attr('nonce', nonce) - }) - } - } - - }) - - 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/nitro/utils/index.ts b/src/runtime/nitro/utils/index.ts index ec1a338b..dd0c3a1c 100644 --- a/src/runtime/nitro/utils/index.ts +++ b/src/runtime/nitro/utils/index.ts @@ -1,10 +1,22 @@ -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): boolean { - return !!getRequestHeader(event, 'x-nitro-prerender') + +import type { NuxtSecurityRouteRules } from "../../../types" +import { createRouter, toRouteMatcher } from "radix3" +import type { H3Event } from "h3" +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: NuxtSecurityRouteRules = defuReplaceArray({}, ...matches.reverse()) + return rules +} + +export function resolveSecurityRoute(event: H3Event) { + const routeRules = event.context.security?.routeRules || {} + const routeNames = Object.fromEntries(Object.entries(routeRules).map(([name]) => [name, { name }])) + const router = createRouter<{ name: string }>({ routes: routeNames}) + const match = router.lookup(event.path.split('?')[0]) + return match?.name } \ No newline at end of file diff --git a/src/runtime/server/middleware/allowedMethodsRestricter.ts b/src/runtime/server/middleware/allowedMethodsRestricter.ts index 5853bf60..b19b333a 100644 --- a/src/runtime/server/middleware/allowedMethodsRestricter.ts +++ b/src/runtime/server/middleware/allowedMethodsRestricter.ts @@ -1,13 +1,16 @@ -import { getRouteRules, defineEventHandler, createError } from '#imports' +import { defineEventHandler, createError } from '#imports' +import { HTTPMethod } from '~/src/module' +import { resolveSecurityRules } from '../../nitro/utils' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const rules = resolveSecurityRules(event) - if (security?.allowedMethodsRestricter) { - const { allowedMethodsRestricter } = security + if (rules.enabled && 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..598a7d9c 100644 --- a/src/runtime/server/middleware/corsHandler.ts +++ b/src/runtime/server/middleware/corsHandler.ts @@ -1,11 +1,12 @@ -import { getRouteRules, defineEventHandler, handleCors } from '#imports' +import { defineEventHandler, handleCors } from '#imports' import type { H3CorsOptions } from 'h3' +import { resolveSecurityRules } from '../../nitro/utils' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const rules = resolveSecurityRules(event) - if (security?.corsHandler) { - const { corsHandler } = security + if (rules.enabled && 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 8e10491d..00000000 --- a/src/runtime/server/middleware/cspNonceHandler.ts +++ /dev/null @@ -1,11 +0,0 @@ -import crypto from 'node:crypto' -import { getRouteRules, defineEventHandler } from '#imports' - -export default defineEventHandler((event) => { - const { security } = getRouteRules(event) - - 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..afe9d394 100644 --- a/src/runtime/server/middleware/rateLimiter.ts +++ b/src/runtime/server/middleware/rateLimiter.ts @@ -1,25 +1,33 @@ import type { H3Event } from 'h3' -import { defineEventHandler, getRequestHeader, createError, setResponseHeader, getRouteRules, useStorage } from '#imports' +import { defineEventHandler, createError, setResponseHeader, useStorage, getRequestIP } from '#imports' import type { RateLimiter } from '~/src/module' +import { resolveSecurityRoute, resolveSecurityRules } from '../../nitro/utils' type StorageItem = { value: number, date: number } -const storage = useStorage('#storage-driver') +const storage = useStorage('#rate-limiter-storage') -export default defineEventHandler(async (event) => { - const { security } = getRouteRules(event) +export default defineEventHandler(async(event) => { + // Disable rate limiter in prerender mode + if (import.meta.prerender) { + return + } + + const rules = resolveSecurityRules(event) - if (security?.rateLimiter) { - const { rateLimiter } = security + if (rules.enabled && rules.rateLimiter) { + const { rateLimiter } = rules const ip = getIP(event) + const route = getRoute(event) + const url = ip + route - let storageItem = await storage.getItem(ip) as StorageItem + let storageItem = await storage.getItem(url) as StorageItem if (!storageItem) { - await setStorageItem(rateLimiter, ip) + await setStorageItem(rateLimiter, url) } else { if (typeof storageItem !== 'object') { return } @@ -27,8 +35,8 @@ export default defineEventHandler(async (event) => { const timeForInterval = storageItem.date + Number(rateLimiter.interval) if (Date.now() >= timeForInterval) { - await setStorageItem(rateLimiter, ip) - storageItem = await storage.getItem(ip) as StorageItem + await setStorageItem(rateLimiter, url) + storageItem = await storage.getItem(url) as StorageItem } const isLimited = timeSinceFirstRateLimit <= timeForInterval && storageItem.value === 0 @@ -39,7 +47,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) @@ -55,8 +63,8 @@ export default defineEventHandler(async (event) => { const newStorageItem: StorageItem = { value: storageItem.value - 1, date: newItemDate } - await storage.setItem(ip, newStorageItem) - const currentItem = await storage.getItem(ip)as StorageItem + await storage.setItem(url, newStorageItem) + const currentItem = await storage.getItem(url)as StorageItem if (currentItem && rateLimiter.headers) { setResponseHeader(event, 'x-ratelimit-remaining', currentItem.value) @@ -67,27 +75,17 @@ export default defineEventHandler(async (event) => { } }) -async function setStorageItem (rateLimiter: RateLimiter, ip: string) { +async function setStorageItem(rateLimiter: Omit, url: string) { const rateLimitedObject: StorageItem = { value: rateLimiter.tokensPerInterval, date: Date.now() } - await storage.setItem(ip, rateLimitedObject) + await storage.setItem(url, rateLimitedObject) } -// Taken and modified from https://github.com/timb-103/nuxt-rate-limit/blob/8a37846469c2f32f0e2ca6893a31baeec944d56c/src/runtime/server/utils/rate-limit.ts#L78 function getIP (event: H3Event) { - const req = event?.node?.req - let xForwardedFor = getRequestHeader(event, 'x-forwarded-for') - - if (xForwardedFor === '::1') { - xForwardedFor = '127.0.0.1' - } - - const transformedXForwardedFor = xForwardedFor?.split(',')?.pop()?.trim() || '' - const remoteAddress = req?.socket?.remoteAddress || '' - let ip = transformedXForwardedFor || remoteAddress - - if (ip) { - ip = ip.split(':')[0] - } - + const ip = getRequestIP(event, { xForwardedFor: true }) || '' return ip } + +function getRoute(event: H3Event) { + const route = resolveSecurityRoute(event) || '' + return route +} diff --git a/src/runtime/server/middleware/requestSizeLimiter.ts b/src/runtime/server/middleware/requestSizeLimiter.ts index 6144f5b9..8778b9b4 100644 --- a/src/runtime/server/middleware/requestSizeLimiter.ts +++ b/src/runtime/server/middleware/requestSizeLimiter.ts @@ -1,11 +1,12 @@ -import { defineEventHandler, getRequestHeader, createError, getRouteRules } from '#imports' +import { defineEventHandler, getRequestHeader, createError } from '#imports' +import { resolveSecurityRules } from '../../nitro/utils' const FILE_UPLOAD_HEADER = 'multipart/form-data' export default defineEventHandler((event) => { - const { security } = getRouteRules(event) + const rules = resolveSecurityRules(event) - if (security?.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') @@ -13,15 +14,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 ffbbb4ba..b28e7504 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -4,20 +4,20 @@ import { createError, getQuery, readBody, - getRouteRules, - readMultipartFormData + readMultipartFormData, } from '#imports' import { HTTPMethod } from '~/src/module' +import { resolveSecurityRules } from '../../nitro/utils' -export default defineEventHandler(async (event) => { - const { security } = getRouteRules(event) +export default defineEventHandler(async(event) => { + const rules = resolveSecurityRules(event) - if (security?.xssValidator) { + if (rules.enabled && 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 } @@ -25,8 +25,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 ) ) { @@ -55,7 +55,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/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/headers.ts b/src/types/headers.ts index 9e780251..d25d5c62 100644 --- a/src/types/headers.ts +++ b/src/types/headers.ts @@ -243,12 +243,3 @@ export interface SecurityHeaders { xXSSProtection?: string | false; permissionsPolicy?: PermissionsPolicyValue | false; } - - -declare module 'h3' { - interface H3EventContext { - security: { - headers: SecurityHeaders - } - } -} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index eb2405f6..e2635eb6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,43 +17,16 @@ export interface ModuleOptions { corsHandler: CorsOptions | false; allowedMethodsRestricter: AllowedHTTPMethods | false; hidePoweredBy: boolean; - basicAuth: BasicAuth | false; enabled: boolean; - csrf: CsrfOptions | boolean; nonce: boolean; - removeLoggers: RemoveOptions | false; ssg: Ssg | false; - /** - * enable runtime nitro hooks to configure some options at runtime - * Current configuration editable at runtime: headers - */ - runtimeHooks: boolean; sri: boolean + basicAuth: BasicAuth | false; + csrf: CsrfOptions | boolean; + removeLoggers: RemoveOptions | false; } -export type NuxtSecurityRouteRules = Pick - - 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 +export type NuxtSecurityRouteRules = Partial< + Omit + & { rateLimiter: Omit | false } +> diff --git a/test/fixtures/hidePoweredBy/server/plugins/error.ts b/test/fixtures/hidePoweredBy/server/plugins/error.ts index dd06c528..d58e3c8d 100644 --- a/test/fixtures/hidePoweredBy/server/plugins/error.ts +++ b/test/fixtures/hidePoweredBy/server/plugins/error.ts @@ -1,5 +1,5 @@ export default defineNitroPlugin(nitroApp => { - nitroApp.hooks.hook('error', async (error, {event}) => { + nitroApp.hooks.hook('error', async (error) => { console.error(error); }); }); \ No newline at end of file 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/rateLimiter/nuxt.config.ts b/test/fixtures/rateLimiter/nuxt.config.ts index fe170b82..276ce3a0 100644 --- a/test/fixtures/rateLimiter/nuxt.config.ts +++ b/test/fixtures/rateLimiter/nuxt.config.ts @@ -9,11 +9,10 @@ export default defineNuxtConfig({ } }, routeRules: { - test: { + '/test': { security: { rateLimiter: { tokensPerInterval: 10, - interval: 'hour' } } } 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 8d9ea963..00000000 --- a/test/fixtures/runtime-hooks/server/api/runtime-hooks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getResponseHeader } from "h3" - -export default defineEventHandler((event) => { - 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 deleted file mode 100644 index db5826e1..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 78% rename from test/fixtures/runtime-hooks/nuxt.config.ts rename to test/fixtures/runtimeHooks/nuxt.config.ts index b01b97cf..e2426ddf 100644 --- a/test/fixtures/runtime-hooks/nuxt.config.ts +++ b/test/fixtures/runtimeHooks/nuxt.config.ts @@ -1,8 +1,6 @@ -import MyModule from '../../../src/module' - export default defineNuxtConfig({ modules: [ - MyModule + '../../../src/module' ], routeRules:{ '/test': { @@ -18,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/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/headers-dynamic.vue b/test/fixtures/runtimeHooks/pages/headers-dynamic.vue new file mode 100644 index 00000000..b72c6da7 --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/headers-dynamic.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/runtimeHooks/pages/headers-static.vue b/test/fixtures/runtimeHooks/pages/headers-static.vue new file mode 100644 index 00000000..cf247eaa --- /dev/null +++ b/test/fixtures/runtimeHooks/pages/headers-static.vue @@ -0,0 +1,3 @@ + 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 new file mode 100644 index 00000000..c2eea4b1 --- /dev/null +++ b/test/fixtures/runtimeHooks/server/api/runtime-headers.ts @@ -0,0 +1,9 @@ + +export default defineEventHandler(() => { + const headers = { + contentSecurityPolicy: { + "script-src": ["'self'", '*.dynamic-value.com', "'nonce-{{nonce}}'"], + } + } + return { headers, hidePoweredBy: false } +}) \ 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..ba434b0f --- /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: '/headers-static', + headers: { + contentSecurityPolicy: { + "script-src": ["'self'", "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: '/headers-dynamic', + headers + }) + }) +}) \ No newline at end of file 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/fixtures/sri/pages/index.vue b/test/fixtures/sri/pages/index.vue index 5e78a49d..0a815662 100644 --- a/test/fixtures/sri/pages/index.vue +++ b/test/fixtures/sri/pages/index.vue @@ -1,7 +1,9 @@ diff --git a/test/hidePoweredBy.test.ts b/test/hidePoweredBy.test.ts index c4c3f2bd..ed585f5a 100644 --- a/test/hidePoweredBy.test.ts +++ b/test/hidePoweredBy.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { fileURLToPath } from 'node:url' -import { setup, fetch, useTestContext } from '@nuxt/test-utils' +import { setup, fetch } from '@nuxt/test-utils' describe('[nuxt-security] Hide Powered-By', async () => { await setup({ 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/rateLimiter.test.ts b/test/rateLimiter.test.ts index 1498bde2..fbe17974 100644 --- a/test/rateLimiter.test.ts +++ b/test/rateLimiter.test.ts @@ -7,19 +7,6 @@ describe('[nuxt-security] Rate Limiter', async () => { rootDir: fileURLToPath(new URL('./fixtures/rateLimiter', import.meta.url)), }) - it ('should return 200 OK after multiple requests for certain route', async () => { - const res1 = await fetch('/test') - const res2 = await fetch('/test') - const res3 = await fetch('/test') - const res4 = await fetch('/test') - const res5 = await fetch('/test') - - expect(res1).toBeDefined() - expect(res1).toBeTruthy() - expect(res5.status).toBe(200) - expect(res5.statusText).toBe('OK') - }) - it ('should return 200 OK when not reaching the limit', async () => { const res1 = await fetch('/') const res2 = await fetch('/') @@ -32,9 +19,9 @@ describe('[nuxt-security] Rate Limiter', async () => { it ('should return 429 Too Many Responses after limit reached', async () => { const res1 = await fetch('/') - const res2 = await fetch('/') - const res3 = await fetch('/') - const res4 = await fetch('/') + await fetch('/') + await fetch('/') + await fetch('/') const res5 = await fetch('/') expect(res1).toBeDefined() @@ -42,4 +29,38 @@ describe('[nuxt-security] Rate Limiter', async () => { expect(res5.status).toBe(429) expect(res5.statusText).toBe('Too Many Requests') }) + + it ('should return 200 OK after multiple requests for a route with a higher limit', async () => { + const res1 = await fetch('/test') + await fetch('/test') + await fetch('/test') + await fetch('/test') + await fetch('/test') + await fetch('/test') + const res5 = await fetch('/test') + + expect(res1).toBeDefined() + expect(res1).toBeTruthy() + expect(res5.status).toBe(200) + expect(res5.statusText).toBe('OK') + }) + + it ('should return 429 when limit reached for a route, but 200 for another route with a higher limit', async () => { + const res1 = await fetch('/') + await fetch('/') + await fetch('/') + await fetch('/') + const res5 = await fetch('/') + + expect(res1).toBeDefined() + expect(res1).toBeTruthy() + expect(res5.status).toBe(429) + expect(res5.statusText).toBe('Too Many Requests') + + const res6 = await fetch('/test') + expect(res6).toBeDefined() + expect(res6).toBeTruthy() + expect(res6.status).toBe(200) + expect(res6.statusText).toBe('OK') + }) }) diff --git a/test/runtime-hooks.test.ts b/test/runtime-hooks.test.ts deleted file mode 100644 index 7fc3334e..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..da0bec1e --- /dev/null +++ b/test/runtimeHooks.test.ts @@ -0,0 +1,35 @@ +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 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; 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; 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; 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).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 diff --git a/test/ssgHashes.test.ts b/test/ssgHashes.test.ts index b3557bb6..2f7eff70 100644 --- a/test/ssgHashes.test.ts +++ b/test/ssgHashes.test.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'node:url' import { describe, it, expect } from 'vitest' import { setup, fetch } from '@nuxt/test-utils' -import exp from 'node:constants' describe('[nuxt-security] SSG support of CSP', async () => { await setup({