From 680cd0a2fcbef255472403178bd5861f3524126a Mon Sep 17 00:00:00 2001 From: Miha Sedej Date: Tue, 28 Feb 2023 17:35:43 +0100 Subject: [PATCH] feat: enable CSP for SSG --- docs/content/1.getting-started/1.setup.md | 8 ++ package.json | 1 + playground/pages/index.vue | 8 +- src/module.ts | 22 ++-- src/runtime/nitro/plugins/cspSsg.ts | 113 ++++++++++++++++++ .../plugins/hidePoweredBy.ts} | 0 6 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 src/runtime/nitro/plugins/cspSsg.ts rename src/runtime/{nitro.ts => nitro/plugins/hidePoweredBy.ts} (100%) diff --git a/docs/content/1.getting-started/1.setup.md b/docs/content/1.getting-started/1.setup.md index 5d02f149..90ff8773 100644 --- a/docs/content/1.getting-started/1.setup.md +++ b/docs/content/1.getting-started/1.setup.md @@ -35,6 +35,14 @@ export default defineNuxtConfig({ That's it! The Nuxt Security module will now register routeRoules and middlewares to make your application more secure ✨ :: +## Static site generation (SSG) + +This module is meant to work with SSR apps but you can also use this module in SSG apps where you will get a Content Security Policy (CSP) support. + +::alert{type="info"} +You can find more about configuring Content Security Policy (CSP) [here](/security/headers#content-security-policy). +:: + ## Configuration You can add configuration to the module like following: diff --git a/package.json b/package.json index e4b76f3d..01798c33 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "prepack": "nuxt-module-build", "dev": "nuxt-module-build --stub && nuxi prepare playground && nuxi dev playground", "dev:build": "nuxi build playground", + "dev:generate": "nuxi generate playground", "dev:prepare": "nuxt-module-build --stub && nuxi prepare playground", "lint": "eslint --ext .js,.ts,.vue", "docs": "cd docs && yarn dev", diff --git a/playground/pages/index.vue b/playground/pages/index.vue index c9997f45..0a6727ad 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -1,3 +1,9 @@ + + diff --git a/src/module.ts b/src/module.ts index 85f5ea7a..f85c4d3f 100644 --- a/src/module.ts +++ b/src/module.ts @@ -46,18 +46,26 @@ export default defineNuxtModule({ // Disabled module when `enabled` is set to `false` if (!securityOptions.enabled) return - // 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) { - nuxt.hook('nitro:config', (config) => { - config.plugins = config.plugins || [] + nuxt.hook('nitro:config', (config) => { + config.plugins = config.plugins || [] + + // 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', import.meta.url))) + normalize(fileURLToPath(new URL('./runtime/nitro/plugins/hidePoweredBy', import.meta.url))) ) - }) - } + } + + // Register nitro plugin to enable CSP for SSG + if (typeof securityOptions.headers === 'object' && securityOptions.headers.contentSecurityPolicy) { + config.plugins.push( + normalize(fileURLToPath(new URL('./runtime/nitro/plugins/cspSsg', import.meta.url))) + ) + } + }) nuxt.options.runtimeConfig.security = defu(nuxt.options.runtimeConfig.security, { ...securityOptions as RuntimeConfig['security'] diff --git a/src/runtime/nitro/plugins/cspSsg.ts b/src/runtime/nitro/plugins/cspSsg.ts new file mode 100644 index 00000000..baae9b41 --- /dev/null +++ b/src/runtime/nitro/plugins/cspSsg.ts @@ -0,0 +1,113 @@ +import path from 'node:path' +import crypto from 'node:crypto' +import type { NitroAppPlugin } from 'nitropack' +import type { H3Event } from 'h3' +import { useRuntimeConfig } from '#imports' +import type { + ModuleOptions, + ContentSecurityPolicyValue, + SecurityHeaders, + MiddlewareConfiguration +} from '../types' + +interface NuxtRenderHTMLContext { + island?: boolean + htmlAttrs: string[] + head: string[] + bodyAttrs: string[] + bodyPrepend: string[] + body: string[] + bodyAppend: string[] +} + +export default function (nitro) { + nitro.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { + // Content Security Policy + const moduleOptions = useRuntimeConfig().security as ModuleOptions + + if (!isContentSecurityPolicyEnabled(event, moduleOptions)) { + return + } + + const scriptPattern = /]*>(.*?)<\/script>/gs + const scriptHashes: string[] = [] + const hashAlgorithm = 'sha256' + + let match + while ((match = scriptPattern.exec(html.bodyAppend.join(''))) !== null) { + if (match[1]) { + scriptHashes.push(generateHash(match[1], hashAlgorithm)) + } + } + + const securityHeaders = moduleOptions.headers as SecurityHeaders + const contentSecurityPolicies: ContentSecurityPolicyValue = (securityHeaders.contentSecurityPolicy as MiddlewareConfiguration).value + + html.head.push(generateCspMetaTag(contentSecurityPolicies, scriptHashes)) + }) + + function generateCspMetaTag(policies: ContentSecurityPolicyValue, scriptHashes: string[]) { + const unsupportedPolicies = { + 'frame-ancestors': true, + 'report-uri': true, + sandbox: true + } + + const tagPolicies = structuredClone(policies) as ContentSecurityPolicyValue + if (scriptHashes.length > 0) { + // Remove '""' + tagPolicies['script-src'] = (tagPolicies['script-src'] ?? []).concat(scriptHashes) + } + + const contentArray: string[] = [] + for (const [key, value] of Object.entries(tagPolicies)) { + if (unsupportedPolicies[key]) { + continue + } + + let policyValue: string + + if (Array.isArray(value)) { + policyValue = value.join(' ') + } else if (typeof value === 'boolean') { + policyValue = '' + } else { + policyValue = value + } + + contentArray.push(`${key} ${policyValue}`) + } + const content = contentArray.join('; ') + + return `` + } + + function generateHash(content: string, hashAlgorithm: string) { + const hash = crypto.createHash(hashAlgorithm) + hash.update(content) + return `'${hashAlgorithm}-${hash.digest('base64')}'` + } + + /** + * Only enable behavior if Content Security pPolicy is enabled, + * initial page is prerendered and generated file type is HTML. + * @param event H3Event + * @param options ModuleOptions + * @returns boolean + */ + function isContentSecurityPolicyEnabled(event: H3Event, options: ModuleOptions): boolean { + const nitroPrerenderHeader = 'x-nitro-prerender' + + // Page is not prerendered + if (!event.node.req.headers[nitroPrerenderHeader]) { + return false + } + + // File is not HTML + if (!['', '.html'].includes(path.extname(event.node.req.headers[nitroPrerenderHeader]))) { + return false + } + + return true + } +} diff --git a/src/runtime/nitro.ts b/src/runtime/nitro/plugins/hidePoweredBy.ts similarity index 100% rename from src/runtime/nitro.ts rename to src/runtime/nitro/plugins/hidePoweredBy.ts