Skip to content

Commit

Permalink
feat: enable CSP for SSG
Browse files Browse the repository at this point in the history
  • Loading branch information
tresko committed Mar 1, 2023
1 parent be7adc7 commit 680cd0a
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 8 deletions.
8 changes: 8 additions & 0 deletions docs/content/1.getting-started/1.setup.md
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion playground/pages/index.vue
@@ -1,3 +1,9 @@
<template>
Home
{{ data }}
</template>

<script setup>
import { useAsyncData } from '#app';
const { data } = await useAsyncData(() => 'Home')
</script>
22 changes: 15 additions & 7 deletions src/module.ts
Expand Up @@ -46,18 +46,26 @@ export default defineNuxtModule<ModuleOptions>({
// 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']
Expand Down
113 changes: 113 additions & 0 deletions 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 <NitroAppPlugin> 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[^>]*>(.*?)<\/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<ContentSecurityPolicyValue>).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 `<meta http-equiv="Content-Security-Policy" content="${content}">`
}

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
}
}
File renamed without changes.

0 comments on commit 680cd0a

Please sign in to comment.