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(csp): Extend CSP support of SSG mode #272

Merged
merged 1 commit into from
Oct 30, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .stackblitz/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ node_modules
.output
.env
dist
.vercel
2 changes: 1 addition & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ const removeCspHeaderForPrerenderedRoutes = (nuxt: Nuxt) => {
const nitroRouteRules = nuxt.options.nitro.routeRules
for (const route in nitroRouteRules) {
const routeRules = nitroRouteRules[route]
if (routeRules.prerender) {
if (routeRules.prerender || nuxt.options.nitro.static) {
routeRules.headers = routeRules.headers || {}
routeRules.headers['Content-Security-Policy'] = ''
}
Expand Down
70 changes: 60 additions & 10 deletions src/runtime/nitro/plugins/02-cspSsg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type {
import type {
ContentSecurityPolicyValue
} from '../../../types/headers'
import { defineNitroPlugin, useRuntimeConfig } from '#imports'
import { defineNitroPlugin, useRuntimeConfig, getRouteRules } from '#imports'
import { useNitro } from '@nuxt/kit'

const moduleOptions = useRuntimeConfig().security

Expand All @@ -24,25 +25,54 @@ export default defineNitroPlugin((nitroApp) => {
return
}

const scriptPattern = /<script[^>]*>(.*?)<\/script>/gs
// Detect bothe inline scripts and inline styles
const inlineScriptPattern = /<script[^>]*>(.*?)<\/script>/gs
const inlineStylePattern = /<style>(.*?)<\/style>/gs
// Whitelist external scripts based on integrity attribute
const externalScriptPattern = /<script .*?integrity="(.*?)".*?(\/>|>.*?<\/script>)/gs
const scriptHashes: string[] = []
const styleHashes: string[] = []
const hashAlgorithm = 'sha256'

let match
while ((match = scriptPattern.exec(html.bodyAppend.join(''))) !== null) {
if (match[1]) {
scriptHashes.push(generateHash(match[1], hashAlgorithm))
// Scan all relevant sections of the NuxtRenderHtmlContext
for (const section of ['body', 'bodyAppend', 'bodyPrepend', 'head']) {
const htmlRecords = html as unknown as Record<string, string[]>
const elements = htmlRecords[section]
for (const element of elements) {
let match
while ((match = inlineScriptPattern.exec(element)) !== null) {
if (match[1]) {
scriptHashes.push(generateHash(match[1], hashAlgorithm))
}
}
while ((match = inlineStylePattern.exec(element)) !== null) {
if (match[1]) {
styleHashes.push(generateHash(match[1], hashAlgorithm))
}
}
while ((match = externalScriptPattern.exec(element)) !== null) {
if (match[1]) {
scriptHashes.push(`'${match[1]}'`)
}
}
}
}

const cspConfig = moduleOptions.headers.contentSecurityPolicy

if (cspConfig && typeof cspConfig !== 'string') {
html.head.push(generateCspMetaTag(cspConfig, scriptHashes))
const content = generateCspMetaTag(cspConfig, scriptHashes, styleHashes)
// Insert hashes in the http meta tag
html.head.push(`<meta http-equiv="Content-Security-Policy" content="${content}">`)
// Also insert hashes in static headers for presets that generate headers rules for static files
updateRouteRules(event, content)
}


})

function generateCspMetaTag (policies: ContentSecurityPolicyValue, scriptHashes: string[]) {
// Insert hashes in the CSP meta tag for both the script-src and the style-src policies
function generateCspMetaTag (policies: ContentSecurityPolicyValue, scriptHashes: string[], styleHashes: string[]) {
const unsupportedPolicies:Record<string, boolean> = {
'frame-ancestors': true,
'report-uri': true,
Expand All @@ -54,6 +84,10 @@ export default defineNitroPlugin((nitroApp) => {
// Remove '""'
tagPolicies['script-src'] = (tagPolicies['script-src'] ?? []).concat(scriptHashes)
}
if (styleHashes.length > 0 && moduleOptions.ssg?.hashScripts) {
// Remove '""'
tagPolicies['style-src'] = (tagPolicies['style-src'] ?? []).concat(styleHashes)
}

const contentArray: string[] = []
for (const [key, value] of Object.entries(tagPolicies)) {
Expand All @@ -75,9 +109,25 @@ export default defineNitroPlugin((nitroApp) => {
contentArray.push(`${key} ${policyValue}`)
}
}
const content = contentArray.join('; ')
const content = contentArray.join('; ').replaceAll("'nonce-{{nonce}}'", '')
return content
}

return `<meta http-equiv="Content-Security-Policy" content="${content}">`
// In some Nitro presets (e.g. Vercel), the header rules are generated for the static server
// By default we update the nitro route rules with their calculated CSP value to support this possibility
function updateRouteRules(event: H3Event, content: string) {
const path = event.path
const routeRules = getRouteRules(event)
let headers
if (routeRules.headers) {
headers = { ...routeRules.headers }
} else {
headers = {}
}
headers['Content-Security-Policy'] = content
routeRules.headers = headers
const nitro = useNitro()
nitro.options.routeRules[path] = routeRules
}

function generateHash (content: string, hashAlgorithm: string) {
Expand Down
Loading