From 424060e4ea865c8ef22e0217330ca5c082652e25 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:18:20 +0300 Subject: [PATCH 1/3] fix: allow the CSP nonce of app.html scripts when a page has csr = false --- .changeset/quiet-nonces-stay.md | 5 ++ packages/kit/src/runtime/server/page/csp.js | 8 ++- .../kit/src/runtime/server/page/csp.spec.js | 67 +++++++++++++++++++ .../kit/src/runtime/server/page/render.js | 3 +- .../kit/src/runtime/server/page/types.d.ts | 2 + packages/kit/test/apps/no-csr/src/app.html | 3 + packages/kit/test/apps/no-csr/test/test.js | 10 +++ 7 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-nonces-stay.md 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 21076d5c274a..0fb2c8834aa3 100644 --- a/packages/kit/src/runtime/server/page/csp.spec.js +++ b/packages/kit/src/runtime/server/page/csp.spec.js @@ -396,6 +396,73 @@ describe.skipIf(!!process.env.DEV)('CSPs in prod', () => { assert.ok(csp.nonce); }); + test('adds the nonce for scripts in the template when the page adds no script', () => { + for (const mode of /** @type {const} */ (['nonce', 'auto'])) { + 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 /** @type {const} */ (['nonce', 'auto'])) { + 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..2c8d4f05bdfb 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..936226c67a7e 100644 --- a/packages/kit/test/apps/no-csr/test/test.js +++ b/packages/kit/test/apps/no-csr/test/test.js @@ -39,3 +39,13 @@ test.describe('Skipped client build', () => { expect(worker_response.status()).toBe(200); }); }); + +test.describe('CSP', () => { + test('allows the nonce that scripts in the template use', async ({ request }) => { + const response = await request.get('/assets'); + const nonce = (await response.text()).match(/ %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 936226c67a7e..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 () => { @@ -41,11 +40,10 @@ test.describe('Skipped client build', () => { }); test.describe('CSP', () => { - test('allows the nonce that scripts in the template use', async ({ request }) => { - const response = await request.get('/assets'); - const nonce = (await response.text()).match(/