diff --git a/lib/common/options.js b/lib/common/options.js index 653dce4145..ce00835594 100644 --- a/lib/common/options.js +++ b/lib/common/options.js @@ -4,7 +4,7 @@ import fs from 'fs' import _ from 'lodash' import consola from 'consola' -import { isUrl, isPureObject } from '../common/utils' +import { isPureObject, isUrl } from '../common/utils' import modes from './modes' import defaults from './nuxt.config' @@ -130,7 +130,8 @@ Options.from = function (_options) { const cspDefaults = { hashAlgorithm: 'sha256', allowedSources: undefined, - policies: undefined + policies: undefined, + reportOnly: options.debug } if (csp) { options.render.csp = _.defaults(_.isObject(csp) ? csp : {}, cspDefaults) diff --git a/lib/core/middleware/nuxt.js b/lib/core/middleware/nuxt.js index 35a4e68256..839f457d3c 100644 --- a/lib/core/middleware/nuxt.js +++ b/lib/core/middleware/nuxt.js @@ -70,7 +70,9 @@ export default async function nuxtMiddleware(req, res, next) { if (this.options.render.csp) { const { allowedSources, policies } = this.options.render.csp - res.setHeader('Content-Security-Policy', getCspString({ cspScriptSrcHashSet, allowedSources, policies, isDev: this.options.dev })) + const cspHeader = this.options.render.csp.reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy' + + res.setHeader(cspHeader, getCspString({ cspScriptSrcHashSet, allowedSources, policies, isDev: this.options.dev })) } // Send response diff --git a/test/unit/basic.ssr.csp.test.js b/test/unit/basic.ssr.csp.test.js index 4d8dc5287d..e8dccffc62 100644 --- a/test/unit/basic.ssr.csp.test.js +++ b/test/unit/basic.ssr.csp.test.js @@ -3,156 +3,314 @@ import { getPort, loadFixture, Nuxt, rp } from '../utils' let port const url = route => 'http://localhost:' + port + route -const startCSPTestServer = async (csp) => { - const options = loadFixture('basic', { render: { csp } }) +const startCspServer = async (csp, isProduction = true) => { + const options = loadFixture('basic', { + debug: !isProduction, + render: { csp } + }) const nuxt = new Nuxt(options) port = await getPort() await nuxt.listen(port, '0.0.0.0') return nuxt } +const getHeader = debug => debug ? 'content-security-policy-report-only' : 'content-security-policy' +const cspHeader = getHeader(false) +const reportOnlyHeader = getHeader(true) + +const startCspDevServer = async csp => startCspServer(csp, false) + describe('basic ssr csp', () => { - test( - 'Not contain Content-Security-Policy header, when csp is false', - async () => { - const nuxt = await startCSPTestServer(false) - const { headers } = await rp(url('/stateless'), { - resolveWithFullResponse: true - }) + let nuxt - expect(headers['content-security-policy']).toBe(undefined) + afterEach(async () => { + await nuxt.close() + }) - await nuxt.close() - } - ) - - test( - 'Contain Content-Security-Policy header, when csp is set', - async () => { - const nuxt = await startCSPTestServer(true) - const { headers } = await rp(url('/stateless'), { - resolveWithFullResponse: true - }) - - expect(headers['content-security-policy']).toMatch(/^script-src 'self' 'sha256-.*'$/) - - await nuxt.close() - } - ) - - 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 () => { - const cspOption = { - allowedSources: ['https://example.com', 'https://example.io'] - } - - const nuxt = await startCSPTestServer(cspOption) - const { headers } = await rp(url('/stateless'), { - resolveWithFullResponse: true - }) - - expect(headers['content-security-policy']).toMatch(/^script-src 'self' 'sha256-.*'/) - expect(headers['content-security-policy'].includes('https://example.com')).toBe(true) - expect(headers['content-security-policy'].includes('https://example.io')).toBe(true) - - await nuxt.close() - } - ) - - test( - 'Contain Content-Security-Policy header, when csp.policies set', - async () => { - const cspOption = { - enabled: true, - policies: { - 'default-src': [`'none'`], - 'script-src': ['https://example.com', 'https://example.io'] - } - } - - const nuxt = await startCSPTestServer(cspOption) - const { headers } = await rp(url('/stateless'), { - resolveWithFullResponse: true - }) - - expect(headers['content-security-policy']).toMatch(/default-src 'none'/) - 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) - - await nuxt.close() - } - ) - - test( - 'Contain Content-Security-Policy header, when csp.policies.script-src is not set', - async () => { - const cspOption = { - enabled: true, - policies: { - 'default-src': [`'none'`] - } - } - - const nuxt = await startCSPTestServer(cspOption) - const { headers } = await rp(url('/stateless'), { - resolveWithFullResponse: true - }) - - expect(headers['content-security-policy']).toMatch(/default-src 'none'/) - 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'), { + describe('production mode', () => { + test( + 'Not contain Content-Security-Policy header, when csp is false', + async () => { + nuxt = await startCspServer(false) + const { headers } = await rp(url('/stateless'), { resolveWithFullResponse: true }) + + expect(headers[cspHeader]).toBe(undefined) } + ) - const { headers } = await rp(url('/stateful'), { - resolveWithFullResponse: true - }) + test( + 'Contain Content-Security-Policy header, when csp is set', + async () => { + nuxt = await startCspServer(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(headers[cspHeader]).toMatch(/^script-src 'self' 'sha256-.*'$/) + } + ) - expect(uniqueHashes.length).toBe(hashes.length) + test( + 'Contain Content-Security-Policy-Report-Only header, when explicitly asked for', + async () => { + nuxt = await startCspDevServer({reportOnly: true}) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) - await nuxt.close() - } - ) + expect(headers[reportOnlyHeader]).toMatch(/^script-src 'self' 'sha256-.*'$/) + } + ) + + test( + 'Contain only unique hashes in header when csp is set', + async () => { + nuxt = await startCspServer(true) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + const hashes = headers[cspHeader].split(' ').filter(s => s.startsWith('\'sha256-')) + const uniqueHashes = [...new Set(hashes)] + + expect(uniqueHashes.length).toBe(hashes.length) + } + ) + + test( + 'Contain Content-Security-Policy header, when csp.allowedSources set', + async () => { + const cspOption = { + allowedSources: ['https://example.com', 'https://example.io'] + } + + nuxt = await startCspServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[cspHeader]).toMatch(/^script-src 'self' 'sha256-.*'/) + expect(headers[cspHeader].includes('https://example.com')).toBe(true) + expect(headers[cspHeader].includes('https://example.io')).toBe(true) + } + ) + + test( + 'Contain Content-Security-Policy header, when csp.policies set', + async () => { + const cspOption = { + enabled: true, + policies: { + 'default-src': [`'none'`], + 'script-src': ['https://example.com', 'https://example.io'] + } + } + + nuxt = await startCspServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[cspHeader]).toMatch(/default-src 'none'/) + expect(headers[cspHeader]).toMatch(/script-src 'sha256-(.*)?' 'self'/) + expect(headers[cspHeader].includes('https://example.com')).toBe(true) + expect(headers[cspHeader].includes('https://example.io')).toBe(true) + } + ) + + test( + 'Contain Content-Security-Policy header, when csp.policies.script-src is not set', + async () => { + const cspOption = { + enabled: true, + policies: { + 'default-src': [`'none'`] + } + } + + nuxt = await startCspServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[cspHeader]).toMatch(/default-src 'none'/) + expect(headers[cspHeader]).toMatch(/script-src 'sha256-.*' 'self'$/) + } + ) + + test( + 'Contain only unique hashes in header when csp.policies is set', + async () => { + const policies = { + 'default-src': [`'self'`], + 'script-src': [`'self'`], + 'style-src': [`'self'`] + } + + nuxt = await startCspServer({ + 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[cspHeader].split(' ').filter(s => s.startsWith('\'sha256-')) + const uniqueHashes = [...new Set(hashes)] + + expect(uniqueHashes.length).toBe(hashes.length) + } + ) + }) + describe('debug mode', () => { + test( + 'Not contain Content-Security-Policy-Report-Only header, when csp is false', + async () => { + nuxt = await startCspDevServer(false) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[reportOnlyHeader]).toBe(undefined) + } + ) + + test( + 'Contain Content-Security-Policy header, when explicitly asked for', + async () => { + nuxt = await startCspDevServer({reportOnly: false}) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[cspHeader]).toMatch(/^script-src 'self' 'sha256-.*'$/) + } + ) + + test( + 'Contain Content-Security-Policy header, when csp is set', + async () => { + nuxt = await startCspDevServer(true) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[reportOnlyHeader]).toMatch(/^script-src 'self' 'sha256-.*'$/) + } + ) + + test( + 'Contain only unique hashes in header when csp is set', + async () => { + nuxt = await startCspDevServer(true) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + const hashes = headers[reportOnlyHeader].split(' ').filter(s => s.startsWith('\'sha256-')) + const uniqueHashes = [...new Set(hashes)] + + expect(uniqueHashes.length).toBe(hashes.length) + } + ) + + test( + 'Contain Content-Security-Policy-Report-Only header, when csp.allowedSources set', + async () => { + const cspOption = { + allowedSources: ['https://example.com', 'https://example.io'] + } + + nuxt = await startCspDevServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[reportOnlyHeader]).toMatch(/^script-src 'self' 'sha256-.*'/) + expect(headers[reportOnlyHeader].includes('https://example.com')).toBe(true) + expect(headers[reportOnlyHeader].includes('https://example.io')).toBe(true) + } + ) + + test( + 'Contain Content-Security-Policy-Report-Only header, when csp.policies set', + async () => { + const cspOption = { + enabled: true, + policies: { + 'default-src': [`'none'`], + 'script-src': ['https://example.com', 'https://example.io'] + } + } + + nuxt = await startCspDevServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[reportOnlyHeader]).toMatch(/default-src 'none'/) + expect(headers[reportOnlyHeader]).toMatch(/script-src 'sha256-(.*)?' 'self'/) + expect(headers[reportOnlyHeader].includes('https://example.com')).toBe(true) + expect(headers[reportOnlyHeader].includes('https://example.io')).toBe(true) + } + ) + + test( + 'Contain Content-Security-Policy-Report-Only header, when csp.policies.script-src is not set', + async () => { + const cspOption = { + enabled: true, + policies: { + 'default-src': [`'none'`] + } + } + + nuxt = await startCspDevServer(cspOption) + const { headers } = await rp(url('/stateless'), { + resolveWithFullResponse: true + }) + + expect(headers[reportOnlyHeader]).toMatch(/default-src 'none'/) + expect(headers[reportOnlyHeader]).toMatch(/script-src 'sha256-.*' 'self'$/) + } + ) + + test( + 'Contain only unique hashes in header when csp.policies is set', + async () => { + const policies = { + 'default-src': [`'self'`], + 'script-src': [`'self'`], + 'style-src': [`'self'`] + } + + nuxt = await startCspDevServer({ + 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[reportOnlyHeader].split(' ').filter(s => s.startsWith('\'sha256-')) + const uniqueHashes = [...new Set(hashes)] + + expect(uniqueHashes.length).toBe(hashes.length) + } + ) + }) })