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)
const {
html,
cspScriptSrcHashes,
cspScriptSrcHashSet,
error,
redirected,
getPreloadFiles
@ -70,29 +70,7 @@ export default async function nuxtMiddleware(req, res, next) {
if (this.options.render.csp) {
const { allowedSources, policies } = this.options.render.csp
let cspStr = `script-src 'self'${this.options.dev ? " 'unsafe-eval'" : ''} ${(cspScriptSrcHashes).join(' ')}`
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)
res.setHeader('Content-Security-Policy', getCspString({ cspScriptSrcHashSet, allowedSources, policies, isDev: this.options.dev }))
}
// Send response
@ -110,3 +88,42 @@ export default async function nuxtMiddleware(req, res, next) {
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 consola from 'consola'
import { isUrl, waitFor, timeout } from '../common/utils'
import { isUrl, timeout, waitFor } from '../common/utils'
import defaults from '../common/nuxt.config'
import MetaRenderer from './meta'
@ -369,14 +369,12 @@ export default class Renderer {
isJSON: true
})};`
const cspScriptSrcHashes = []
const cspScriptSrcHashSet = new Set()
if (this.options.render.csp) {
const { hashAlgorithm } = this.options.render.csp
let hash = crypto.createHash(hashAlgorithm)
hash.update(serializedSession)
cspScriptSrcHashes.push(
`'${hashAlgorithm}-${hash.digest('base64')}'`
)
cspScriptSrcHashSet.add(`'${hashAlgorithm}-${hash.digest('base64')}'`)
}
APP += `<script>${serializedSession}</script>`
@ -396,7 +394,7 @@ export default class Renderer {
return {
html,
cspScriptSrcHashes,
cspScriptSrcHashSet,
getPreloadFiles: context.getPreloadFiles,
error: context.nuxt.error,
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
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(
'Contain Content-Security-Policy header, when csp.allowedSources set',
async () => {
@ -77,7 +94,7 @@ describe('basic ssr csp', () => {
})
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.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(/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()
}