diff --git a/.changeset/quiet-nonces-stay.md b/.changeset/quiet-nonces-stay.md new file mode 100644 index 000000000000..bd2ad77540ce --- /dev/null +++ b/.changeset/quiet-nonces-stay.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: add the CSP nonce for scripts in `app.html` to `script-src` when a page has `csr = false` diff --git a/packages/kit/src/runtime/server/page/csp.js b/packages/kit/src/runtime/server/page/csp.js index 4a0782d7f241..f7267f6bad06 100644 --- a/packages/kit/src/runtime/server/page/csp.js +++ b/packages/kit/src/runtime/server/page/csp.js @@ -331,10 +331,16 @@ export class Csp { * @param {import('./types.js').CspConfig} config * @param {import('./types.js').CspOpts} opts */ - constructor({ mode, directives, reportOnly }, { prerender }) { + constructor({ mode, directives, reportOnly }, { prerender, template_contains_nonce }) { this.#use_hashes = mode === 'hash' || (mode === 'auto' && prerender); this.csp_provider = new CspProvider(this.#use_hashes, directives); this.report_only_provider = new CspReportOnlyProvider(this.#use_hashes, reportOnly); + + // scripts in the template can use the nonce whether or not the page adds any of its own + if (template_contains_nonce && !this.#use_hashes) { + this.csp_provider.add_script(`nonce-${this.nonce}`); + this.report_only_provider.add_script(`nonce-${this.nonce}`); + } } /** diff --git a/packages/kit/src/runtime/server/page/csp.spec.js b/packages/kit/src/runtime/server/page/csp.spec.js index 4e9f2301c790..eca5a50075c6 100644 --- a/packages/kit/src/runtime/server/page/csp.spec.js +++ b/packages/kit/src/runtime/server/page/csp.spec.js @@ -395,6 +395,76 @@ describe('CSPs in prod', () => { assert.ok(csp.nonce); }); + /** @type {Array<'nonce' | 'auto'>} */ + const nonce_modes = ['nonce', 'auto']; + + test('adds the nonce for scripts in the template when the page adds no script', () => { + for (const mode of nonce_modes) { + const csp = new Csp( + { + mode, + directives: { + 'script-src': ['self'] + }, + reportOnly: { + 'script-src': ['self'], + 'report-uri': ['/'] + } + }, + { + prerender: false, + template_contains_nonce: true + } + ); + + assert.equal(csp.csp_provider.get_header(), `script-src 'self' 'nonce-${csp.nonce}'`); + assert.equal( + csp.report_only_provider.get_header(), + `script-src 'self' 'nonce-${csp.nonce}'; report-uri /` + ); + } + }); + + test('does not add a nonce when neither the template nor the page uses one', () => { + for (const mode of nonce_modes) { + for (const template_contains_nonce of [false, undefined]) { + const csp = new Csp( + { + mode, + directives: { + 'script-src': ['self'] + }, + reportOnly: {} + }, + { + prerender: false, + template_contains_nonce + } + ); + + assert.equal(csp.csp_provider.get_header(), "script-src 'self'"); + } + } + }); + + test('does not add the template nonce when using hashes', () => { + const csp = new Csp( + { + mode: 'hash', + directives: { + 'script-src': ['self'] + }, + reportOnly: {} + }, + { + prerender: false, + template_contains_nonce: true + } + ); + + assert.equal(csp.csp_provider.get_header(), "script-src 'self'"); + }); + test('throws when reportOnly contains directives but no report-uri or report-to', () => { expect(() => { new Csp( diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0a8042eb03fa..1129d27820a3 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -118,7 +118,8 @@ export async function render_response({ let base_expression = s(paths.base); const csp = new Csp(options.csp, { - prerender: !!(state.prerendering || state.prerender_default === true) + prerender: !!(state.prerendering || state.prerender_default === true), + template_contains_nonce: options.app_template_contains_nonce }); // if appropriate, use relative paths for greater portability diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 7eaa50b9a814..d358340a76ce 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -34,6 +34,8 @@ export interface CspConfig { export interface CspOpts { prerender: boolean; + /** Whether the app template contains `%sveltekit.nonce%` */ + template_contains_nonce?: boolean; } export interface Cookie { diff --git a/packages/kit/test/apps/no-csr/src/app.html b/packages/kit/test/apps/no-csr/src/app.html index b8ba4699b2ba..ae77aada5a4b 100644 --- a/packages/kit/test/apps/no-csr/src/app.html +++ b/packages/kit/test/apps/no-csr/src/app.html @@ -3,6 +3,9 @@ + %sveltekit.head% diff --git a/packages/kit/test/apps/no-csr/test/test.js b/packages/kit/test/apps/no-csr/test/test.js index 10fc6b005dd1..4e1cb099ec6a 100644 --- a/packages/kit/test/apps/no-csr/test/test.js +++ b/packages/kit/test/apps/no-csr/test/test.js @@ -3,11 +3,10 @@ import process from 'node:process'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; -test.skip(({ javaScriptEnabled }) => javaScriptEnabled); - test.describe.configure({ mode: 'parallel' }); test.describe('Skipped client build', () => { + test.skip(({ javaScriptEnabled }) => javaScriptEnabled); test.skip(!!process.env.DEV); test('omits client build output', async () => { @@ -39,3 +38,12 @@ test.describe('Skipped client build', () => { expect(worker_response.status()).toBe(200); }); }); + +test.describe('CSP', () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); + + test('runs the scripts in the template that use the nonce', async ({ page }) => { + await page.goto('/assets', { wait_for_started: false }); + await expect(page.locator('html')).toHaveAttribute('data-template-script', 'ran'); + }); +});