diff --git a/.changeset/silent-sites-end.md b/.changeset/silent-sites-end.md new file mode 100644 index 000000000000..af6f74dd5ab8 --- /dev/null +++ b/.changeset/silent-sites-end.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: `config.kit.csp.directives['trusted-types']` requires `'svelte-trusted-html'` (and `'sveltekit-trusted-url'` when a service worker is automatically registered) if it is configured diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index ae35a708132b..42146a89d4c3 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -3,6 +3,7 @@ import path from 'node:path'; import process from 'node:process'; import * as url from 'node:url'; import options from './options.js'; +import { resolve_entry } from '../../utils/filesystem.js'; /** * Loads the template (src/app.html by default) and validates that it has the @@ -96,7 +97,7 @@ export async function load_config({ cwd = process.cwd() } = {}) { * @returns {import('types').ValidatedConfig} */ function process_config(config, { cwd = process.cwd() } = {}) { - const validated = validate_config(config); + const validated = validate_config(config, cwd); validated.kit.outDir = path.resolve(cwd, validated.kit.outDir); @@ -116,15 +117,17 @@ function process_config(config, { cwd = process.cwd() } = {}) { /** * @param {import('@sveltejs/kit').Config} config + * @param {string} [cwd] * @returns {import('types').ValidatedConfig} */ -export function validate_config(config) { +export function validate_config(config, cwd = process.cwd()) { if (typeof config !== 'object') { throw new Error( 'The Svelte config file must have a configuration object as its default export. See https://svelte.dev/docs/kit/configuration' ); } + /** @type {import('types').ValidatedConfig} */ const validated = options(config, 'config'); const files = validated.kit.files; @@ -151,5 +154,22 @@ export function validate_config(config) { } } + if (validated.kit.csp?.directives?.['require-trusted-types-for']?.includes('script')) { + if (!validated.kit.csp?.directives?.['trusted-types']?.includes('svelte-trusted-html')) { + throw new Error( + "The `csp.directives['trusted-types']` option must include 'svelte-trusted-html'" + ); + } + if ( + validated.kit.serviceWorker?.register && + resolve_entry(path.resolve(cwd, validated.kit.files.serviceWorker)) && + !validated.kit.csp?.directives?.['trusted-types']?.includes('sveltekit-trusted-url') + ) { + throw new Error( + "The `csp.directives['trusted-types']` option must include 'sveltekit-trusted-url' when `serviceWorker.register` is true" + ); + } + } + return validated; } diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index d16ba4253582..53a0dc0166b7 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -1,7 +1,8 @@ +/** @import { Validator } from './types.js' */ + import process from 'node:process'; import colors from 'kleur'; - -/** @typedef {import('./types.js').Validator} Validator */ +import { supportsTrustedTypes } from '../sync/utils.js'; const directives = object({ 'child-src': string_array(), @@ -28,8 +29,14 @@ const directives = object({ 'navigate-to': string_array(), 'report-uri': string_array(), 'report-to': string_array(), - 'require-trusted-types-for': string_array(), - 'trusted-types': string_array(), + 'require-trusted-types-for': validate(undefined, (input, keypath) => { + assert_trusted_types_supported(keypath); + return string_array()(input, keypath); + }), + 'trusted-types': validate(undefined, (input, keypath) => { + assert_trusted_types_supported(keypath); + return string_array()(input, keypath); + }), 'upgrade-insecure-requests': boolean(false), 'require-sri-for': string_array(), 'block-all-mixed-content': boolean(false), @@ -485,4 +492,13 @@ function assert_string(input, keypath) { } } +/** @param {string} keypath */ +function assert_trusted_types_supported(keypath) { + if (!supportsTrustedTypes()) { + throw new Error( + `${keypath} is not supported by your version of Svelte. Please upgrade to Svelte 5.51.0 or later to use this directive.` + ); + } +} + export default options; diff --git a/packages/kit/src/core/sync/utils.js b/packages/kit/src/core/sync/utils.js index be3ecc22b347..eb7d392fbd1f 100644 --- a/packages/kit/src/core/sync/utils.js +++ b/packages/kit/src/core/sync/utils.js @@ -6,6 +6,8 @@ import { import_peer } from '../../utils/import.js'; /** @type {{ VERSION: string }} */ const { VERSION } = await import_peer('svelte/compiler'); +const [MAJOR, MINOR] = VERSION.split('.').map(Number); + /** @type {Map} */ const previous_contents = new Map(); @@ -74,5 +76,9 @@ export function dedent(strings, ...values) { } export function isSvelte5Plus() { - return Number(VERSION[0]) >= 5; + return MAJOR >= 5; +} + +export function supportsTrustedTypes() { + return (MAJOR === 5 && MINOR >= 51) || MAJOR > 5; } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 7648a757e02c..6a5553dd8723 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -550,8 +550,14 @@ export async function render_response({ // we use an anonymous function instead of an arrow function to support // older browsers (https://github.com/sveltejs/kit/pull/5417) blocks.push(`if ('serviceWorker' in navigator) { + const script_url = '${prefixed('service-worker.js')}'; + const policy = globalThis?.window?.trustedTypes?.createPolicy( + 'sveltekit-trusted-url', + { createScriptURL(url) { return url; } } + ); + const sanitised = policy?.createScriptURL(script_url) ?? script_url; addEventListener('load', function () { - navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts}); + navigator.serviceWorker.register(sanitised${opts}); }); }`); } diff --git a/packages/kit/test/apps/options-2/package.json b/packages/kit/test/apps/options-2/package.json index bb41653fb9f9..a660525133b6 100644 --- a/packages/kit/test/apps/options-2/package.json +++ b/packages/kit/test/apps/options-2/package.json @@ -10,7 +10,7 @@ "check": "svelte-kit sync && tsc && svelte-check", "test": "pnpm test:dev && pnpm test:build", "test:dev": "DEV=true playwright test", - "test:build": "playwright test" + "test:build": "playwright test && REGISTER_SERVICE_WORKER=true playwright test" }, "devDependencies": { "@sveltejs/adapter-node": "workspace:^", diff --git a/packages/kit/test/apps/options-2/src/routes/csp-trusted-types/+page.svelte b/packages/kit/test/apps/options-2/src/routes/csp-trusted-types/+page.svelte new file mode 100644 index 000000000000..4dcd9dd822b7 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/csp-trusted-types/+page.svelte @@ -0,0 +1 @@ +

this page will error when SvelteKit tries to register the service worker

diff --git a/packages/kit/test/apps/options-2/svelte.config.js b/packages/kit/test/apps/options-2/svelte.config.js index 1fee777858a0..04867a5b220b 100644 --- a/packages/kit/test/apps/options-2/svelte.config.js +++ b/packages/kit/test/apps/options-2/svelte.config.js @@ -1,12 +1,20 @@ +import process from 'node:process'; + /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { + csp: { + directives: { + 'require-trusted-types-for': ['script'], + 'trusted-types': ['svelte-trusted-html', 'sveltekit-trusted-url'] + } + }, paths: { base: '/basepath', relative: true }, serviceWorker: { - register: false + register: !!process.env.REGISTER_SERVICE_WORKER }, env: { dir: '../../env' diff --git a/packages/kit/test/apps/options-2/test/service-worker.test.js b/packages/kit/test/apps/options-2/test/service-worker.test.js new file mode 100644 index 000000000000..2447abfa3e1c --- /dev/null +++ b/packages/kit/test/apps/options-2/test/service-worker.test.js @@ -0,0 +1,54 @@ +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; + +test.skip(({ javaScriptEnabled }) => !javaScriptEnabled || !process.env.REGISTER_SERVICE_WORKER); + +test('import proxy /basepath/service-worker.js', async ({ request }) => { + test.skip(!process.env.DEV); + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const response = await request.get('/basepath/service-worker.js'); + const content = await response.text(); + expect(content).toEqual( + `import '${path.join('/basepath', '/@fs', __dirname, '../src/service-worker.js')}';` + ); +}); + +test('build /basepath/service-worker.js', async ({ baseURL, request }) => { + test.skip(!!process.env.DEV); + + const response = await request.get('/basepath/service-worker.js'); + const content = await response.text(); + + const fn = new Function('self', 'location', content); + + const self = { + addEventListener: () => {}, + base: null, + build: null + }; + + const pathname = '/basepath/service-worker.js'; + + fn(self, { + href: baseURL + pathname, + pathname + }); + + expect(self.base).toBe('/basepath'); + expect(self.build?.[0]).toMatch(/\/basepath\/_app\/immutable\/bundle\.[\w-]+\.js/); + expect(self.image_src).toMatch(/\/basepath\/_app\/immutable\/assets\/image\.[\w-]+\.jpg/); +}); + +test('works with CSP require-trusted-types-for', async ({ page }) => { + const errors = []; + page.on('pageerror', (err) => { + errors.push(err.message); + }); + + await page.goto('/basepath/csp-trusted-types'); + expect(errors.length).toEqual(0); +}); diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index f3df40146a8d..888645765dd0 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -1,10 +1,8 @@ -import path from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; -/** @typedef {import('@playwright/test').Response} Response */ +test.skip(() => !!process.env.REGISTER_SERVICE_WORKER); test.describe.configure({ mode: 'parallel' }); @@ -107,59 +105,22 @@ test.describe('paths', () => { }); test.describe('trailing slash', () => { - if (!process.env.DEV) { - test('trailing slash server prerendered without server load', async ({ - page, - clicknav, - javaScriptEnabled - }) => { - if (!javaScriptEnabled) return; - - await page.goto('/basepath/trailing-slash-server'); - - await clicknav('a[href="/basepath/trailing-slash-server/prerender"]'); - expect(await page.textContent('h2')).toBe('/basepath/trailing-slash-server/prerender/'); - }); - } + test('trailing slash server prerendered without server load', async ({ + page, + clicknav, + javaScriptEnabled + }) => { + test.skip(!javaScriptEnabled || !process.env.DEV); + + await page.goto('/basepath/trailing-slash-server'); + + await clicknav('a[href="/basepath/trailing-slash-server/prerender"]'); + expect(await page.textContent('h2')).toBe('/basepath/trailing-slash-server/prerender/'); + }); }); test.describe('Service worker', () => { - if (process.env.DEV) { - test('import proxy /basepath/service-worker.js', async ({ request }) => { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const response = await request.get('/basepath/service-worker.js'); - const content = await response.text(); - expect(content).toEqual( - `import '${path.join('/basepath', '/@fs', __dirname, '../src/service-worker.js')}';` - ); - }); - - return; - } - - test('build /basepath/service-worker.js', async ({ baseURL, request }) => { - const response = await request.get('/basepath/service-worker.js'); - const content = await response.text(); - - const fn = new Function('self', 'location', content); - - const self = { - addEventListener: () => {}, - base: null, - build: null - }; - - const pathname = '/basepath/service-worker.js'; - - fn(self, { - href: baseURL + pathname, - pathname - }); - - expect(self.base).toBe('/basepath'); - expect(self.build?.[0]).toMatch(/\/basepath\/_app\/immutable\/bundle\.[\w-]+\.js/); - expect(self.image_src).toMatch(/\/basepath\/_app\/immutable\/assets\/image\.[\w-]+\.jpg/); - }); + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); test('does not register /basepath/service-worker.js', async ({ page }) => { await page.goto('/basepath'); diff --git a/packages/kit/test/apps/options/source/pages/+layout.svelte b/packages/kit/test/apps/options/source/pages/+layout.svelte index 65d3d04b2070..5e1f1fed86c2 100644 --- a/packages/kit/test/apps/options/source/pages/+layout.svelte +++ b/packages/kit/test/apps/options/source/pages/+layout.svelte @@ -1,20 +1,7 @@ diff --git a/packages/kit/test/apps/options/source/pages/csp-trusted-types/+page.svelte b/packages/kit/test/apps/options/source/pages/csp-trusted-types/+page.svelte new file mode 100644 index 000000000000..f0168f02432d --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/csp-trusted-types/+page.svelte @@ -0,0 +1 @@ +

this page will error when Svelte tries to set the innerHTML without a trusted type

diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js index 4852cc71596d..da8cebe35818 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -8,7 +8,8 @@ const config = { csp: { directives: { 'script-src': ['self'], - 'require-trusted-types-for': ['script'] + 'require-trusted-types-for': ['script'], + 'trusted-types': ['svelte-trusted-html'] } }, files: { diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index 87a6c02d7534..ceefb0383b18 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -60,6 +60,18 @@ test.describe('CSP', () => { expect(hydratable_script_match).not.toBeNull(); expect(hydratable_script_match?.[1]).toBe(nonce); }); + + test('require-trusted-types-for', async ({ page, javaScriptEnabled }) => { + test.skip(!javaScriptEnabled, 'trusted types only affects scripts'); + + const errors = []; + page.on('pageerror', (err) => { + errors.push(err.message); + }); + + await page.goto('/path-base/csp-trusted-types'); + expect(errors.length).toEqual(0); + }); }); test.describe('Custom extensions', () => { diff --git a/packages/kit/test/prerendering/basics/test/tests.spec.js b/packages/kit/test/prerendering/basics/test/tests.spec.js index e3972a1d369d..adc1d198154f 100644 --- a/packages/kit/test/prerendering/basics/test/tests.spec.js +++ b/packages/kit/test/prerendering/basics/test/tests.spec.js @@ -245,7 +245,7 @@ test('prerenders a page in a (group)', () => { test('injects relative service worker', () => { const content = read('index.html'); - expect(content).toMatch("navigator.serviceWorker.register('./service-worker.js'"); + expect(content).toMatch("const script_url = './service-worker.js';"); }); test('define service worker variables', () => {