diff --git a/lib/common/options.js b/lib/common/options.js index 819d7a0e7e..7d6b952f4b 100755 --- a/lib/common/options.js +++ b/lib/common/options.js @@ -34,6 +34,9 @@ Options.from = function (_options) { if (typeof options.extensions === 'string') { options.extensions = [ options.extensions ] } + if (options.render.csp === true) { + options.render.csp = { hashAlgorithm: 'sha256' } + } const hasValue = v => typeof v === 'string' && v options.rootDir = hasValue(options.rootDir) ? options.rootDir : process.cwd() @@ -278,7 +281,8 @@ Options.defaults = { }, etag: { weak: false - } + }, + csp: undefined }, watchers: { webpack: { diff --git a/lib/core/middleware/nuxt.js b/lib/core/middleware/nuxt.js index 4e8ba2368b..76e343479e 100644 --- a/lib/core/middleware/nuxt.js +++ b/lib/core/middleware/nuxt.js @@ -11,7 +11,7 @@ module.exports = async function nuxtMiddleware(req, res, next) { try { const result = await this.renderRoute(req.url, context) await this.nuxt.callHook('render:route', req.url, result) - const { html, error, redirected, getPreloadFiles } = result + const { html, cspScriptSrcHashes, error, redirected, getPreloadFiles } = result if (redirected) { return html @@ -61,6 +61,10 @@ module.exports = async function nuxtMiddleware(req, res, next) { res.setHeader('Link', pushAssets.join(',')) } + if (this.options.render.csp) { + res.setHeader('Content-Security-Policy', `script-src 'self' ${(cspScriptSrcHashes || []).join(' ')}`) + } + // Send response res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Length', Buffer.byteLength(html)) diff --git a/lib/core/renderer.js b/lib/core/renderer.js index 834ad80f27..1eae2e9f48 100644 --- a/lib/core/renderer.js +++ b/lib/core/renderer.js @@ -9,6 +9,7 @@ const { createBundleRenderer } = require('vue-server-renderer') const Debug = require('debug') const connect = require('connect') const launchMiddleware = require('launch-editor-middleware') +const crypto = require('crypto') const { setAnsiColors, isUrl, waitFor } = require('../common/utils') const { Options } = require('../common') @@ -315,7 +316,15 @@ module.exports = class Renderer { HEAD += context.renderResourceHints() } - APP += `` + let serializedSession = `window.__NUXT__=${serialize(context.nuxt, { isJSON: true })};` + let cspScriptSrcHashes = [] + if (this.options.render.csp) { + let hash = crypto.createHash(this.options.render.csp.hashAlgorithm) + hash.update(serializedSession) + cspScriptSrcHashes.push(`'${this.options.render.csp.hashAlgorithm}-${hash.digest('base64')}'`) + } + + APP += `` APP += context.renderScripts() APP += m.script.text({ body: true }) @@ -331,6 +340,7 @@ module.exports = class Renderer { return { html, + cspScriptSrcHashes, getPreloadFiles: context.getPreloadFiles, error: context.nuxt.error, redirected: context.redirected diff --git a/test/basic.ssr.test.js b/test/basic.ssr.test.js index c18aaf5708..33a4c94e1f 100755 --- a/test/basic.ssr.test.js +++ b/test/basic.ssr.test.js @@ -22,6 +22,9 @@ test.serial('Init Nuxt.js', async t => { }, build: { stats: false + }, + render: { + csp: true } } @@ -228,6 +231,12 @@ test('ETag Header', async t => { t.is(error.statusCode, 304) }) +test('Content-Security-Policy Header', async t => { + const { headers } = await rp(url('/stateless'), { resolveWithFullResponse: true }) + // Verify functionality + t.is(headers['content-security-policy'], "script-src 'self' 'sha256-BBvfKxDOoRM/gnFwke9u60HBZX3HUss/0lSI1sBRvOU='") +}) + test('/_nuxt/server-bundle.json should return 404', async t => { const err = await t.throws(rp(url('/_nuxt/server-bundle.json'), { resolveWithFullResponse: true })) t.is(err.statusCode, 404)