diff --git a/src/module.ts b/src/module.ts index 6c9cbde6..2e399509 100644 --- a/src/module.ts +++ b/src/module.ts @@ -11,6 +11,7 @@ import { defaultSecurityConfig } from './defaultConfig' import type { Nuxt } from '@nuxt/schema' import type { Nitro } from 'nitropack' import type { ModuleOptions } from './types/module' +import { modifyCorsHandlerOriginRegExpToJSON } from './utils/originRegExpSerde' export * from './types/module' export * from './types/headers' @@ -23,7 +24,7 @@ export default defineNuxtModule({ }, async setup (options, nuxt) { const resolver = createResolver(import.meta.url) - + nuxt.options.build.transpile.push(resolver.resolve('./runtime')) // First merge module options with default options @@ -55,6 +56,9 @@ export default defineNuxtModule({ // At this point we have all security options merged into runtimeConfig const securityOptions = nuxt.options.runtimeConfig.security + // Avoid JSON.stringify regexp to '{}' + modifyCorsHandlerOriginRegExpToJSON(securityOptions.corsHandler) + // Disable module when `enabled` is set to `false` if (!securityOptions.enabled) { return } @@ -75,6 +79,9 @@ export default defineNuxtModule({ // Then insert route specific security headers for (const route in nuxt.options.nitro.routeRules) { const rule = nuxt.options.nitro.routeRules[route] + if (rule.security && rule.security.corsHandler) { + modifyCorsHandlerOriginRegExpToJSON(rule.security.corsHandler) + } if (rule.security && rule.security.headers) { const { security : { headers } } = rule const routeSecurityHeaders = getHeadersApplicableToAllResources(headers) @@ -84,7 +91,7 @@ export default defineNuxtModule({ ) } } - + // Register nitro plugin to manage security rules at the level of each route addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules')) @@ -135,12 +142,12 @@ export default defineNuxtModule({ addServerHandler({ handler: resolver.resolve('./runtime/server/middleware/rateLimiter') }) - + // Register XSS validator middleware addServerHandler({ handler: resolver.resolve('./runtime/server/middleware/xssValidator') }) - + // Register basicAuth middleware that is disabled by default const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth if (basicAuthConfig && (basicAuthConfig.enabled || (basicAuthConfig as any)?.value?.enabled)) { @@ -174,12 +181,12 @@ export default defineNuxtModule({ }) // Register init hook to add pre-rendered headers to responses - nuxt.hook('nitro:init', nitro => { + nuxt.hook('nitro:init', nitro => { nitro.hooks.hook('prerender:done', async() => { // Add the prenredered headers to the Nitro server assets - nitro.options.serverAssets.push({ - baseName: 'nuxt-security', - dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security') + nitro.options.serverAssets.push({ + baseName: 'nuxt-security', + dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security') }) // In some Nitro presets (e.g. Vercel), the header rules are generated for the static server @@ -203,7 +210,7 @@ export default defineNuxtModule({ }) /** - * + * * Register storage driver for the rate limiter */ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) { @@ -229,8 +236,8 @@ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) * Make sure our nitro plugins will be applied last, * After all other third-party modules that might have loaded their own nitro plugins */ -function reorderNitroPlugins(nuxt: Nuxt) { - nuxt.hook('nitro:init', nitro => { +function reorderNitroPlugins(nuxt: Nuxt) { + nuxt.hook('nitro:init', nitro => { const resolver = createResolver(import.meta.url) const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') diff --git a/src/runtime/nitro/plugins/00-routeRules.ts b/src/runtime/nitro/plugins/00-routeRules.ts index 007a9781..805f59b1 100644 --- a/src/runtime/nitro/plugins/00-routeRules.ts +++ b/src/runtime/nitro/plugins/00-routeRules.ts @@ -2,6 +2,7 @@ import { defineNitroPlugin, useRuntimeConfig } from "#imports" import { getAppSecurityOptions } from '../context' import { defuReplaceArray } from '../../../utils/merge' import { standardToSecurity, backwardsCompatibleSecurity } from '../../../utils/headers' +import { getRegExpOriginRestoredCorsHandler } from '../../../utils/originRegExpSerde'; /** * This plugin merges all security options into the global security context @@ -24,9 +25,15 @@ export default defineNitroPlugin(async(nitroApp) => { const securityOptions = runtimeConfig.security const { headers } = securityOptions + // Restore origin regexp + const corsHandlerOption = getRegExpOriginRestoredCorsHandler(securityOptions.corsHandler) + const securityHeaders = backwardsCompatibleSecurity(headers) appSecurityOptions['/**'] = defuReplaceArray( - { headers: securityHeaders }, + { + headers: securityHeaders, + corsHandler: corsHandlerOption, + }, securityOptions, appSecurityOptions['/**'] ) @@ -40,8 +47,12 @@ export default defineNitroPlugin(async(nitroApp) => { if (security) { const { headers } = security const securityHeaders = backwardsCompatibleSecurity(headers) + const corsHandlerOption = getRegExpOriginRestoredCorsHandler(security.corsHandler) appSecurityOptions[route] = defuReplaceArray( - { headers: securityHeaders }, + { + headers: securityHeaders, + corsHandler: corsHandlerOption + }, security, appSecurityOptions[route], ) @@ -63,4 +74,3 @@ export default defineNitroPlugin(async(nitroApp) => { await nitroApp.hooks.callHook('nuxt-security:ready') }) - diff --git a/src/utils/originRegExpSerde.ts b/src/utils/originRegExpSerde.ts new file mode 100644 index 00000000..216b06b0 --- /dev/null +++ b/src/utils/originRegExpSerde.ts @@ -0,0 +1,41 @@ +import type { CorsOptions } from '../module'; + +const REG_SERIALIZE_KEY = '_nuxt_security_origin_serialized_regexp'; + +export const modifyRegExpToJSON = (reg: RegExp) => { + (reg as any).toJSON = () => ({ + [REG_SERIALIZE_KEY]: reg.toString(), + }); +}; + +export const getDeserializedRegExp = (regObj: Record) => { + const regStr = regObj?.[REG_SERIALIZE_KEY]; + if (typeof regStr !== 'string') return null; + const fragments = regStr.match(/\/(.*?)\/([a-z]*)?$/i); + if (!fragments?.length) return null; + return new RegExp(fragments?.[1], fragments?.[2] || ''); +}; + +export const modifyCorsHandlerOriginRegExpToJSON = (corsHandler?: CorsOptions | false) => { + if (typeof corsHandler === 'object' && Array.isArray(corsHandler.origin)) { + corsHandler.origin.forEach((o) => { + o instanceof RegExp && modifyRegExpToJSON(o); + }); + } +}; + +export const getRegExpOriginRestoredCorsHandler = (corsHandler?: CorsOptions | false) => { + let originOption = typeof corsHandler === 'object' ? corsHandler.origin : undefined; + if (typeof corsHandler !== 'object') return corsHandler; + if (Array.isArray(corsHandler.origin)) { + originOption = corsHandler.origin.map((o) => { + const origin = getDeserializedRegExp(o as any); + return origin ?? o; + }); + } + const result: CorsOptions = { + ...corsHandler, + origin: originOption, + }; + return result; +};