UNPKG

nuxt-security

Version:

🛡️ Security Module for Nuxt based on HTTP Headers and Middleware

241 lines (238 loc) 9.03 kB
import { defineNuxtModule, createResolver, addVitePlugin, addServerPlugin, addServerHandler, installModule, addImportsDir, addServerImports, useNitro } from '@nuxt/kit'; import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { join, isAbsolute } from 'pathe'; import { defu } from 'defu'; import viteRemove from 'unplugin-remove/vite'; import { getHeadersApplicableToAllResources } from './utils/headers.mjs'; import { generateHash } from './utils/crypto.mjs'; import { defuReplaceArray } from './utils/merge.mjs'; import { defaultSecurityConfig } from './defaultConfig.mjs'; const module = defineNuxtModule({ meta: { name: "nuxt-security", configKey: "security" }, async setup(options, nuxt) { const resolver = createResolver(import.meta.url); nuxt.options.build.transpile.push(resolver.resolve("./runtime")); const strict = options.strict || nuxt.options.security?.strict || nuxt.options.runtimeConfig.security?.strict || false; nuxt.options.security = defuReplaceArray( { ...options, ...nuxt.options.security }, { ...defaultSecurityConfig(nuxt.options.devServer.url, strict) } ); nuxt.options.runtimeConfig.private = defu( nuxt.options.runtimeConfig.private, { basicAuth: nuxt.options.security.basicAuth } ); delete nuxt.options.security.basicAuth; nuxt.options.runtimeConfig.security = defu( nuxt.options.runtimeConfig.security, { ...nuxt.options.security } ); const securityOptions = nuxt.options.runtimeConfig.security; if (!securityOptions.enabled) { return; } if (securityOptions.removeLoggers) { if (securityOptions.removeLoggers !== true) { addVitePlugin(viteRemove(securityOptions.removeLoggers)); } else if (!nuxt.options.dev) { if (nuxt.options.vite.build?.minify === "terser") { nuxt.options.vite.build = defu( { terserOptions: { compress: { drop_console: true, drop_debugger: true } } }, nuxt.options.vite.build ); } else { nuxt.options.vite.esbuild = defu( { drop: ["console", "debugger"] }, nuxt.options.vite.esbuild ); } } } if (securityOptions.headers) { const globalSecurityHeaders = getHeadersApplicableToAllResources(securityOptions.headers); nuxt.options.nitro.routeRules = defuReplaceArray( { "/**": { headers: globalSecurityHeaders } }, nuxt.options.nitro.routeRules ); } for (const route in nuxt.options.nitro.routeRules) { const rule = nuxt.options.nitro.routeRules[route]; if (rule.security && rule.security.headers) { const { security: { headers } } = rule; const routeSecurityHeaders = getHeadersApplicableToAllResources(headers); nuxt.options.nitro.routeRules[route] = defuReplaceArray( { headers: routeSecurityHeaders }, rule ); } } addServerPlugin(resolver.resolve("./runtime/nitro/plugins/00-routeRules")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/20-subresourceIntegrity")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/30-cspSsgHashes")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/40-cspSsrNonce")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/50-updateCsp")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/60-recombineHtml")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/70-securityHeaders")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/80-hidePoweredBy")); addServerPlugin(resolver.resolve("./runtime/nitro/plugins/90-prerenderedHeaders")); reorderNitroPlugins(nuxt); addServerHandler({ handler: resolver.resolve("./runtime/server/middleware/requestSizeLimiter") }); addServerHandler({ handler: resolver.resolve("./runtime/server/middleware/corsHandler") }); addServerHandler({ handler: resolver.resolve("./runtime/server/middleware/allowedMethodsRestricter") }); registerRateLimiterStorage(nuxt, securityOptions); addServerHandler({ handler: resolver.resolve("./runtime/server/middleware/rateLimiter") }); addServerHandler({ handler: resolver.resolve("./runtime/server/middleware/xssValidator") }); const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth; if (basicAuthConfig && (basicAuthConfig.enabled || basicAuthConfig?.value?.enabled)) { addServerHandler({ route: basicAuthConfig.route || "", handler: resolver.resolve("./runtime/server/middleware/basicAuth") }); } if (securityOptions.csrf) { if (Object.keys(securityOptions.csrf).length) { await installModule("nuxt-csurf", securityOptions.csrf); } else { await installModule("nuxt-csurf"); } } addImportsDir(resolver.resolve("./runtime/composables")); addServerImports([{ name: "defuReplaceArray", from: resolver.resolve("./utils/merge") }]); let sriHashes = {}; nuxt.options.nitro.virtual = defu( { "#sri-hashes": () => `export default ${JSON.stringify(sriHashes)}` }, nuxt.options.nitro.virtual ); nuxt.hook("nitro:build:before", async (nitro) => { sriHashes = await hashBundledAssets(nitro); }); nuxt.hook("nitro:init", (nitro) => { nitro.hooks.hook("prerender:done", async () => { nitro.options.serverAssets.push({ baseName: "nuxt-security", dir: createResolver(nuxt.options.buildDir).resolve("./nuxt-security") }); const prerenderedHeaders = await nitro.storage.getItem("build:nuxt-security:headers.json") || {}; if (securityOptions.ssg && securityOptions.ssg.exportToPresets) { const prerenderedHeadersRouteRules = Object.fromEntries(Object.entries(prerenderedHeaders).map(([route, headers]) => [route, { headers }])); const n = useNitro(); n.options.routeRules = defuReplaceArray( prerenderedHeadersRouteRules, n.options.routeRules ); } nuxt.hooks.callHook("nuxt-security:prerenderedHeaders", prerenderedHeaders); }); }); } }); function registerRateLimiterStorage(nuxt, securityOptions) { nuxt.hook("nitro:config", (config) => { const driver = defu( securityOptions.rateLimiter ? securityOptions.rateLimiter.driver : void 0, { name: "lruCache" } ); const { name, options = {} } = driver; config.storage = defu( { "#rate-limiter-storage": { driver: name, ...options } }, config.storage ); }); } function reorderNitroPlugins(nuxt) { nuxt.hook("nitro:init", (nitro) => { const resolver = createResolver(import.meta.url); const securityPluginsPrefix = resolver.resolve("./runtime/nitro/plugins"); nitro.options.plugins.sort((a, b) => { if (a.startsWith(securityPluginsPrefix)) { if (b.startsWith(securityPluginsPrefix)) { return 0; } else { return 1; } } else { if (b.startsWith(securityPluginsPrefix)) { return -1; } else { return 0; } } }); nitro.hooks.hook("prerender:config", (config) => { config.plugins?.sort((a, b) => { if (a?.startsWith(securityPluginsPrefix)) { if (b?.startsWith(securityPluginsPrefix)) { return 0; } else { return 1; } } else { if (b?.startsWith(securityPluginsPrefix)) { return -1; } else { return 0; } } }); }); }); } async function hashBundledAssets(nitro) { const hashAlgorithm = "SHA-384"; const sriHashes = {}; const { cdnURL: appCdnUrl = "", baseURL: appBaseUrl } = nitro.options.runtimeConfig.app; const publicAssets = nitro.options.publicAssets; for (const publicAsset of publicAssets) { const { dir, baseURL = "" } = publicAsset; if (existsSync(dir)) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile()) { const path = join(dir, entry.name); const content = await readFile(path); const hash = await generateHash(content, hashAlgorithm); const fullPath = join(baseURL, entry.name); let url; if (appCdnUrl) { const relativePath = isAbsolute(fullPath) ? fullPath.slice(1) : fullPath; const abdsoluteCdnUrl = appCdnUrl.endsWith("/") ? appCdnUrl : appCdnUrl + "/"; url = new URL(relativePath, abdsoluteCdnUrl).href; } else { url = join("/", appBaseUrl, fullPath); } sriHashes[url] = hash; } } } } return sriHashes; } export { module as default };