Skip to content

Commit

Permalink
feat(csp): support generating nonce for scripts and links in ssr (#9621)
Browse files Browse the repository at this point in the history
  • Loading branch information
clarkdo committed Jun 7, 2023
1 parent 7cd2b19 commit 89204f0
Show file tree
Hide file tree
Showing 5 changed files with 49 additions and 8 deletions.
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

0 comments on commit 89204f0

Please sign in to comment.