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): support generating nonce for scripts and links in ssr #9621

Merged
merged 4 commits into from Jun 7, 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
3 changes: 2 additions & 1 deletion packages/config/src/options.js
Expand Up @@ -280,7 +280,8 @@ export function getNuxtConfig (_options) {
policies: undefined,
addMeta: Boolean(options.target === TARGETS.static),
unsafeInlineCompatibility: false,
reportOnly: options.debug
reportOnly: options.debug,
generateNonce: false
})

// TODO: Remove this if statement in Nuxt 3, we will stop supporting this typo (more on: https://github.com/nuxt/nuxt.js/pull/6583)
Expand Down
6 changes: 4 additions & 2 deletions packages/config/test/options.test.js
Expand Up @@ -116,7 +116,8 @@ describe('config: options', () => {
allowedSources: ['/nuxt/*'],
policies: undefined,
reportOnly: false,
test: true
test: true,
generateNonce: false
})
})

Expand All @@ -130,7 +131,8 @@ describe('config: options', () => {
allowedSources: ['/nuxt/*'],
policies: undefined,
reportOnly: false,
test: true
test: true,
generateNonce: false
})
})

Expand Down
1 change: 1 addition & 0 deletions packages/types/config/render.d.ts
Expand Up @@ -34,6 +34,7 @@ interface CspOptions {
addMeta?: boolean
allowedSources?: string[]
hashAlgorithm?: string
generateNonce?: boolean
policies?: Partial<Record<CspPolicyName, string[]>>
reportOnly?: boolean
unsafeInlineCompatibility?: boolean
Expand Down
27 changes: 22 additions & 5 deletions packages/vue-renderer/src/renderers/ssr.js
Expand Up @@ -21,7 +21,7 @@ export default class SSRRenderer extends BaseRenderer {
}
}

addAttrs (tags, referenceTag, referenceAttr) {
addAttrs (renderContext, tags, referenceTag, referenceAttr) {
const reference = referenceTag ? `<${referenceTag}` : referenceAttr
if (!reference) {
return tags
Expand All @@ -35,23 +35,31 @@ export default class SSRRenderer extends BaseRenderer {
)
}

const { req } = renderContext
if (req && typeof req.__nonce_value__ === 'string') {
tags = tags.replace(
new RegExp(reference, 'g'),
`${reference} nonce="${req.__nonce_value__}"`
)
}

return tags
}

renderResourceHints (renderContext) {
return this.addAttrs(renderContext.renderResourceHints(), null, 'rel="preload"')
return this.addAttrs(renderContext, renderContext.renderResourceHints(), null, 'rel="preload"')
}

renderScripts (renderContext) {
let renderedScripts = this.addAttrs(renderContext.renderScripts(), 'script')
let renderedScripts = this.addAttrs(renderContext, renderContext.renderScripts(), 'script')
if (this.options.render.asyncScripts) {
renderedScripts = renderedScripts.replace(/defer>/g, 'defer async>')
}
return renderedScripts
}

renderStyles (renderContext) {
return this.addAttrs(renderContext.renderStyles(), 'link')
return this.addAttrs(renderContext, renderContext.renderStyles(), 'link')
}

getPreloadFiles (renderContext) {
Expand Down Expand Up @@ -152,6 +160,12 @@ export default class SSRRenderer extends BaseRenderer {
meta.noscript.text()
}

const { csp } = this.options.render
const { req = {} } = renderContext
if (csp && csp.generateNonce === true) {
req.__nonce_value__ = crypto.randomBytes(32).toString('hex')
}

// Check if we need to inject scripts and state
const shouldInjectScripts = this.options.render.injectScripts !== false

Expand All @@ -178,7 +192,6 @@ export default class SSRRenderer extends BaseRenderer {
}
}

const { csp } = this.options.render
// Only add the hash if 'unsafe-inline' rule isn't present to avoid conflicts (#5387)
const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes('\'unsafe-inline\'')
const shouldHashCspScriptSrc = csp && (csp.unsafeInlineCompatibility || !containsUnsafeInlineScriptSrc)
Expand Down Expand Up @@ -257,6 +270,10 @@ export default class SSRRenderer extends BaseRenderer {
}
}

if (req.__nonce_value__) {
cspScriptSrcHashes.push(`'nonce-${req.__nonce_value__}'`)
}

// Call ssr:csp hook
await this.serverContext.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes)

Expand Down
20 changes: 20 additions & 0 deletions test/dev/basic.ssr.csp.test.js
Expand Up @@ -217,6 +217,26 @@ describe('basic ssr csp', () => {
}
)

test('Contain nonce on ssr links and scripts', async () => {
nuxt = await startCspServer({
generateNonce: true
})

const { body, headers } = await rp(url('/stateless'))

expect(headers[cspHeader]).toMatch(/script-src .* 'nonce-.*'/)

const nonceValue = headers[cspHeader].match(/'nonce-(.*?)'/)[1]

for (const link of body.match(/<link[^>]+?>/g)) {
expect(link).toContain(`nonce="${nonceValue}"`)
}

for (const script of body.match(/<script[^>]+?>/g)) {
expect(script).toContain(`nonce="${nonceValue}"`)
}
})

// TODO: Remove this test in Nuxt 3, we will stop supporting this typo (more on: https://github.com/nuxt/nuxt.js/pull/6583)
test(
'Contain hash and \'unsafe-inline\' when the typo property unsafeInlineCompatiblity is enabled',
Expand Down