Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable CSP for SSG #112

Merged
merged 1 commit into from Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.