fix(csp): remove duplicate sha-256 hashes (#3574)

This commit is contained in:
Alexander Lichter 2018-07-26 15:48:28 +02:00 committed by Sébastien Chopin
parent 241a071a3e
commit a37772f0f8
3 changed files with 97 additions and 33 deletions

View File

@ -14,7 +14,7 @@ export default async function nuxtMiddleware(req, res, next) {
await this.nuxt.callHook('render:route', req.url, result, context) await this.nuxt.callHook('render:route', req.url, result, context)
const { const {
html, html,
cspScriptSrcHashes, cspScriptSrcHashSet,
error, error,
redirected, redirected,
getPreloadFiles getPreloadFiles
@ -70,29 +70,7 @@ export default async function nuxtMiddleware(req, res, next) {
if (this.options.render.csp) { if (this.options.render.csp) {
const { allowedSources, policies } = this.options.render.csp const { allowedSources, policies } = this.options.render.csp
let cspStr = `script-src 'self'${this.options.dev ? " 'unsafe-eval'" : ''} ${(cspScriptSrcHashes).join(' ')}` res.setHeader('Content-Security-Policy', getCspString({ cspScriptSrcHashSet, allowedSources, policies, isDev: this.options.dev }))
if (Array.isArray(allowedSources)) {
// For compatible section
cspStr += ' ' + allowedSources.join(' ')
} else if (typeof policies === 'object' && policies !== null && !Array.isArray(policies)) {
// Set default policy if necessary
if (!policies['script-src'] || !Array.isArray(policies['script-src'])) {
policies['script-src'] = [`'self'`].concat(cspScriptSrcHashes)
} else {
policies['script-src'] = cspScriptSrcHashes.concat(policies['script-src'])
if (!policies['script-src'].includes(`'self'`)) {
policies['script-src'] = [`'self'`].concat(policies['script-src'])
}
}
// Make content-security-policy string
let cspArr = []
Object.keys(policies).forEach((k) => {
cspArr.push(`${k} ${policies[k].join(' ')}`)
})
cspStr = cspArr.join('; ')
}
res.setHeader('Content-Security-Policy', cspStr)
} }
// Send response // Send response
@ -110,3 +88,42 @@ export default async function nuxtMiddleware(req, res, next) {
next(err) next(err)
} }
} }
const getCspString = ({ cspScriptSrcHashSet, allowedSources, policies, isDev }) => {
const joinedHashSet = Array.from(cspScriptSrcHashSet).join(' ')
const baseCspStr = `script-src 'self'${isDev ? ` 'unsafe-eval'` : ''} ${joinedHashSet}`
if (Array.isArray(allowedSources)) {
return `${baseCspStr} ${allowedSources.join(' ')}`
}
const policyObjectAvailable = typeof policies === 'object' && policies !== null && !Array.isArray(policies)
if (policyObjectAvailable) {
const transformedPolicyObject = transformPolicyObject(policies, cspScriptSrcHashSet)
return Object.entries(transformedPolicyObject).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ')
}
return baseCspStr
}
const transformPolicyObject = (policies, cspScriptSrcHashSet) => {
const userHasDefinedScriptSrc = policies['script-src'] && Array.isArray(policies['script-src'])
// Self is always needed for inline-scripts, so add it, no matter if the user specified script-src himself.
const hashAndPolicySet = cspScriptSrcHashSet
hashAndPolicySet.add(`'self'`)
if (!userHasDefinedScriptSrc) {
policies['script-src'] = Array.from(hashAndPolicySet)
return policies
}
new Set(policies['script-src']).forEach(src => hashAndPolicySet.add(src))
policies['script-src'] = Array.from(hashAndPolicySet)
return policies
}

View File

@ -11,7 +11,7 @@ import connect from 'connect'
import launchMiddleware from 'launch-editor-middleware' import launchMiddleware from 'launch-editor-middleware'
import consola from 'consola' import consola from 'consola'
import { isUrl, waitFor, timeout } from '../common/utils' import { isUrl, timeout, waitFor } from '../common/utils'
import defaults from '../common/nuxt.config' import defaults from '../common/nuxt.config'
import MetaRenderer from './meta' import MetaRenderer from './meta'
@ -369,14 +369,12 @@ export default class Renderer {
isJSON: true isJSON: true
})};` })};`
const cspScriptSrcHashes = [] const cspScriptSrcHashSet = new Set()
if (this.options.render.csp) { if (this.options.render.csp) {
const { hashAlgorithm } = this.options.render.csp const { hashAlgorithm } = this.options.render.csp
let hash = crypto.createHash(hashAlgorithm) let hash = crypto.createHash(hashAlgorithm)
hash.update(serializedSession) hash.update(serializedSession)
cspScriptSrcHashes.push( cspScriptSrcHashSet.add(`'${hashAlgorithm}-${hash.digest('base64')}'`)
`'${hashAlgorithm}-${hash.digest('base64')}'`
)
} }
APP += `<script>${serializedSession}</script>` APP += `<script>${serializedSession}</script>`
@ -396,7 +394,7 @@ export default class Renderer {
return { return {
html, html,
cspScriptSrcHashes, cspScriptSrcHashSet,
getPreloadFiles: context.getPreloadFiles, getPreloadFiles: context.getPreloadFiles,
error: context.nuxt.error, error: context.nuxt.error,
redirected: context.redirected redirected: context.redirected

View File

@ -1,4 +1,4 @@
import { loadFixture, getPort, Nuxt, rp } from '../utils' import { getPort, loadFixture, Nuxt, rp } from '../utils'
let port let port
const url = route => 'http://localhost:' + port + route const url = route => 'http://localhost:' + port + route
@ -40,6 +40,23 @@ describe('basic ssr csp', () => {
} }
) )
test(
'Contain only unique hashes in header when csp is set',
async () => {
const nuxt = await startCSPTestServer(true)
const { headers } = await rp(url('/stateless'), {
resolveWithFullResponse: true
})
const hashes = headers['content-security-policy'].split(' ').filter(s => s.startsWith('\'sha256-'))
const uniqueHashes = [...new Set(hashes)]
expect(uniqueHashes.length).toBe(hashes.length)
await nuxt.close()
}
)
test( test(
'Contain Content-Security-Policy header, when csp.allowedSources set', 'Contain Content-Security-Policy header, when csp.allowedSources set',
async () => { async () => {
@ -77,7 +94,7 @@ describe('basic ssr csp', () => {
}) })
expect(headers['content-security-policy']).toMatch(/default-src 'none'/) expect(headers['content-security-policy']).toMatch(/default-src 'none'/)
expect(headers['content-security-policy']).toMatch(/script-src 'self' 'sha256-.*'/) expect(headers['content-security-policy']).toMatch(/script-src 'sha256-(.*)?' 'self'/)
expect(headers['content-security-policy'].includes('https://example.com')).toBe(true) expect(headers['content-security-policy'].includes('https://example.com')).toBe(true)
expect(headers['content-security-policy'].includes('https://example.io')).toBe(true) expect(headers['content-security-policy'].includes('https://example.io')).toBe(true)
@ -101,7 +118,39 @@ describe('basic ssr csp', () => {
}) })
expect(headers['content-security-policy']).toMatch(/default-src 'none'/) expect(headers['content-security-policy']).toMatch(/default-src 'none'/)
expect(headers['content-security-policy']).toMatch(/script-src 'self' 'sha256-.*'/) expect(headers['content-security-policy']).toMatch(/script-src 'sha256-.*' 'self'$/)
await nuxt.close()
}
)
test(
'Contain only unique hashes in header when csp.policies is set',
async () => {
const policies = {
'default-src': [`'self'`],
'script-src': [`'self'`],
'style-src': [`'self'`]
}
const nuxt = await startCSPTestServer({
policies
})
for (let i = 0; i < 5; i++) {
await rp(url('/stateless'), {
resolveWithFullResponse: true
})
}
const { headers } = await rp(url('/stateful'), {
resolveWithFullResponse: true
})
const hashes = headers['content-security-policy'].split(' ').filter(s => s.startsWith('\'sha256-'))
const uniqueHashes = [...new Set(hashes)]
expect(uniqueHashes.length).toBe(hashes.length)
await nuxt.close() await nuxt.close()
} }