Skip to content

Commit aa466ec

Browse files
teemingcvercel[bot]Rich-Harris
authored
fix: add support for trusted types CSP (#15323)
closes #7975 This PR: - adds a validation error that guides the user to update their Svelte version when using the CSP directive `require-trusted-types-for` or `trusted-types`. - uses a trusted policy to register the service worker when possible - errors if the svelte policy name is not included when trusted-types is configured - errors if the sveltekit policy name is not included when trusted-types is configured and a service worker exists and is automatically registered by us. We don't care if the user is registering the service worker on their own because then they can create their own trusted policy while doing so ### Open questions 1. Should we automatically add `svelte-trusted-html` to `trusted-types` if `require-trusted-types-for` is in use? - This will cause any other trusted type policies to throw an error. 2. Or should we only add `svelte-trusted-html` when the user has `trusted-types` configured? - This means any trusted type policy is permitted on the page, which kind of defeats the purpose. 3. Or should we do nothing? - This means any trusted type policy is permitted on the page, which kind of defeats the purpose. - If the user has `trusted-types` configured but omitted `svelte-trusted-html`, they will have to discover the error message in the browser console logs, and figure out that they have to add the svelte trusted type to the config themselves. 5. Or should we error when the `trusted-types` config option isn't configured alongside the `require-trusted-types-for` option? - This will help teach users to add `svelte-trusted-html` themselves. - Could be kind of annoying and/or limiting. --- ### Please don't delete this checklist! Before submitting the PR, please make sure you do the following: - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. ### Tests - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check` ### Changesets - [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. Changesets that add features should be `minor` and those that fix bugs should be `patch`. Please prefix changeset messages with `feat:`, `fix:`, or `chore:`. ### Edits - [x] Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed. --------- Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com> Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
1 parent 3290a0c commit aa466ec

15 files changed

Lines changed: 158 additions & 79 deletions

File tree

.changeset/silent-sites-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
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

packages/kit/src/core/config/index.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'node:path';
33
import process from 'node:process';
44
import * as url from 'node:url';
55
import options from './options.js';
6+
import { resolve_entry } from '../../utils/filesystem.js';
67

78
/**
89
* 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() } = {}) {
9697
* @returns {import('types').ValidatedConfig}
9798
*/
9899
function process_config(config, { cwd = process.cwd() } = {}) {
99-
const validated = validate_config(config);
100+
const validated = validate_config(config, cwd);
100101

101102
validated.kit.outDir = path.resolve(cwd, validated.kit.outDir);
102103

@@ -116,15 +117,17 @@ function process_config(config, { cwd = process.cwd() } = {}) {
116117

117118
/**
118119
* @param {import('@sveltejs/kit').Config} config
120+
* @param {string} [cwd]
119121
* @returns {import('types').ValidatedConfig}
120122
*/
121-
export function validate_config(config) {
123+
export function validate_config(config, cwd = process.cwd()) {
122124
if (typeof config !== 'object') {
123125
throw new Error(
124126
'The Svelte config file must have a configuration object as its default export. See https://svelte.dev/docs/kit/configuration'
125127
);
126128
}
127129

130+
/** @type {import('types').ValidatedConfig} */
128131
const validated = options(config, 'config');
129132
const files = validated.kit.files;
130133

@@ -151,5 +154,22 @@ export function validate_config(config) {
151154
}
152155
}
153156

157+
if (validated.kit.csp?.directives?.['require-trusted-types-for']?.includes('script')) {
158+
if (!validated.kit.csp?.directives?.['trusted-types']?.includes('svelte-trusted-html')) {
159+
throw new Error(
160+
"The `csp.directives['trusted-types']` option must include 'svelte-trusted-html'"
161+
);
162+
}
163+
if (
164+
validated.kit.serviceWorker?.register &&
165+
resolve_entry(path.resolve(cwd, validated.kit.files.serviceWorker)) &&
166+
!validated.kit.csp?.directives?.['trusted-types']?.includes('sveltekit-trusted-url')
167+
) {
168+
throw new Error(
169+
"The `csp.directives['trusted-types']` option must include 'sveltekit-trusted-url' when `serviceWorker.register` is true"
170+
);
171+
}
172+
}
173+
154174
return validated;
155175
}

packages/kit/src/core/config/options.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
/** @import { Validator } from './types.js' */
2+
13
import process from 'node:process';
24
import colors from 'kleur';
3-
4-
/** @typedef {import('./types.js').Validator} Validator */
5+
import { supportsTrustedTypes } from '../sync/utils.js';
56

67
const directives = object({
78
'child-src': string_array(),
@@ -28,8 +29,14 @@ const directives = object({
2829
'navigate-to': string_array(),
2930
'report-uri': string_array(),
3031
'report-to': string_array(),
31-
'require-trusted-types-for': string_array(),
32-
'trusted-types': string_array(),
32+
'require-trusted-types-for': validate(undefined, (input, keypath) => {
33+
assert_trusted_types_supported(keypath);
34+
return string_array()(input, keypath);
35+
}),
36+
'trusted-types': validate(undefined, (input, keypath) => {
37+
assert_trusted_types_supported(keypath);
38+
return string_array()(input, keypath);
39+
}),
3340
'upgrade-insecure-requests': boolean(false),
3441
'require-sri-for': string_array(),
3542
'block-all-mixed-content': boolean(false),
@@ -486,4 +493,13 @@ function assert_string(input, keypath) {
486493
}
487494
}
488495

496+
/** @param {string} keypath */
497+
function assert_trusted_types_supported(keypath) {
498+
if (!supportsTrustedTypes()) {
499+
throw new Error(
500+
`${keypath} is not supported by your version of Svelte. Please upgrade to Svelte 5.51.0 or later to use this directive.`
501+
);
502+
}
503+
}
504+
489505
export default options;

packages/kit/src/core/sync/utils.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { import_peer } from '../../utils/import.js';
66
/** @type {{ VERSION: string }} */
77
const { VERSION } = await import_peer('svelte/compiler');
88

9+
const [MAJOR, MINOR] = VERSION.split('.').map(Number);
10+
911
/** @type {Map<string, string>} */
1012
const previous_contents = new Map();
1113

@@ -74,5 +76,10 @@ export function dedent(strings, ...values) {
7476
}
7577

7678
export function isSvelte5Plus() {
77-
return Number(VERSION[0]) >= 5;
79+
return MAJOR >= 5;
80+
}
81+
82+
// TODO 3.0 remove this once we can bump the peerDep range
83+
export function supportsTrustedTypes() {
84+
return (MAJOR === 5 && MINOR >= 51) || MAJOR > 5;
7885
}

packages/kit/src/runtime/server/page/render.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,14 @@ export async function render_response({
614614
// we use an anonymous function instead of an arrow function to support
615615
// older browsers (https://github.com/sveltejs/kit/pull/5417)
616616
blocks.push(`if ('serviceWorker' in navigator) {
617+
const script_url = '${prefixed('service-worker.js')}';
618+
const policy = globalThis?.window?.trustedTypes?.createPolicy(
619+
'sveltekit-trusted-url',
620+
{ createScriptURL(url) { return url; } }
621+
);
622+
const sanitised = policy?.createScriptURL(script_url) ?? script_url;
617623
addEventListener('load', function () {
618-
navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts});
624+
navigator.serviceWorker.register(sanitised${opts});
619625
});
620626
}`);
621627
}

packages/kit/test/apps/options-2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"check": "svelte-kit sync && tsc && svelte-check",
1111
"test": "pnpm test:dev && pnpm test:build",
1212
"test:dev": "DEV=true playwright test",
13-
"test:build": "playwright test"
13+
"test:build": "playwright test && REGISTER_SERVICE_WORKER=true playwright test"
1414
},
1515
"devDependencies": {
1616
"@sveltejs/adapter-node": "workspace:^",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>this page will error when SvelteKit tries to register the service worker</p>

packages/kit/test/apps/options-2/svelte.config.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import process from 'node:process';
2+
13
/** @type {import('@sveltejs/kit').Config} */
24
const config = {
35
compilerOptions: {
@@ -6,12 +8,18 @@ const config = {
68
}
79
},
810
kit: {
11+
csp: {
12+
directives: {
13+
'require-trusted-types-for': ['script'],
14+
'trusted-types': ['svelte-trusted-html', 'sveltekit-trusted-url']
15+
}
16+
},
917
paths: {
1018
base: '/basepath',
1119
relative: true
1220
},
1321
serviceWorker: {
14-
register: false
22+
register: !!process.env.REGISTER_SERVICE_WORKER
1523
},
1624
env: {
1725
dir: '../../env'
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import path from 'node:path';
2+
import process from 'node:process';
3+
import { fileURLToPath } from 'node:url';
4+
import { expect } from '@playwright/test';
5+
import { test } from '../../../utils.js';
6+
7+
test.skip(({ javaScriptEnabled }) => !javaScriptEnabled || !process.env.REGISTER_SERVICE_WORKER);
8+
9+
test('import proxy /basepath/service-worker.js', async ({ request }) => {
10+
test.skip(!process.env.DEV);
11+
12+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
13+
const response = await request.get('/basepath/service-worker.js');
14+
const content = await response.text();
15+
expect(content).toEqual(
16+
`import '${path.join('/basepath', '/@fs', __dirname, '../src/service-worker.js')}';`
17+
);
18+
});
19+
20+
test('build /basepath/service-worker.js', async ({ baseURL, request }) => {
21+
test.skip(!!process.env.DEV);
22+
23+
const response = await request.get('/basepath/service-worker.js');
24+
const content = await response.text();
25+
26+
const fn = new Function('self', 'location', content);
27+
28+
const self = {
29+
addEventListener: () => {},
30+
base: null,
31+
build: null,
32+
image_src: undefined
33+
};
34+
35+
const pathname = '/basepath/service-worker.js';
36+
37+
fn(self, {
38+
href: baseURL + pathname,
39+
pathname
40+
});
41+
42+
expect(self.base).toBe('/basepath');
43+
expect(self.build?.[0]).toMatch(/\/basepath\/_app\/immutable\/bundle\.[\w-]+\.js/);
44+
expect(self.image_src).toMatch(/\/basepath\/_app\/immutable\/assets\/image\.[\w-]+\.jpg/);
45+
});
46+
47+
test('works with CSP require-trusted-types-for', async ({ page }) => {
48+
const errors = [];
49+
page.on('pageerror', (err) => {
50+
errors.push(err.message);
51+
});
52+
53+
await page.goto('/basepath/csp-trusted-types');
54+
expect(errors.length).toEqual(0);
55+
});

packages/kit/test/apps/options-2/test/test.js

Lines changed: 14 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import path from 'node:path';
21
import process from 'node:process';
3-
import { fileURLToPath } from 'node:url';
42
import { expect } from '@playwright/test';
53
import { test } from '../../../utils.js';
64

7-
/** @typedef {import('@playwright/test').Response} Response */
5+
test.skip(() => !!process.env.REGISTER_SERVICE_WORKER);
86

97
test.describe.configure({ mode: 'parallel' });
108

@@ -107,60 +105,22 @@ test.describe('paths', () => {
107105
});
108106

109107
test.describe('trailing slash', () => {
110-
if (!process.env.DEV) {
111-
test('trailing slash server prerendered without server load', async ({
112-
page,
113-
clicknav,
114-
javaScriptEnabled
115-
}) => {
116-
if (!javaScriptEnabled) return;
117-
118-
await page.goto('/basepath/trailing-slash-server');
119-
120-
await clicknav('a[href="/basepath/trailing-slash-server/prerender"]');
121-
expect(await page.textContent('h2')).toBe('/basepath/trailing-slash-server/prerender/');
122-
});
123-
}
108+
test('trailing slash server prerendered without server load', async ({
109+
page,
110+
clicknav,
111+
javaScriptEnabled
112+
}) => {
113+
test.skip(!javaScriptEnabled || !process.env.DEV);
114+
115+
await page.goto('/basepath/trailing-slash-server');
116+
117+
await clicknav('a[href="/basepath/trailing-slash-server/prerender"]');
118+
expect(await page.textContent('h2')).toBe('/basepath/trailing-slash-server/prerender/');
119+
});
124120
});
125121

126122
test.describe('Service worker', () => {
127-
if (process.env.DEV) {
128-
test('import proxy /basepath/service-worker.js', async ({ request }) => {
129-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
130-
const response = await request.get('/basepath/service-worker.js');
131-
const content = await response.text();
132-
expect(content).toEqual(
133-
`import '${path.join('/basepath', '/@fs', __dirname, '../src/service-worker.js')}';`
134-
);
135-
});
136-
137-
return;
138-
}
139-
140-
test('build /basepath/service-worker.js', async ({ baseURL, request }) => {
141-
const response = await request.get('/basepath/service-worker.js');
142-
const content = await response.text();
143-
144-
const fn = new Function('self', 'location', content);
145-
146-
const self = {
147-
addEventListener: () => {},
148-
base: null,
149-
build: null,
150-
image_src: undefined
151-
};
152-
153-
const pathname = '/basepath/service-worker.js';
154-
155-
fn(self, {
156-
href: baseURL + pathname,
157-
pathname
158-
});
159-
160-
expect(self.base).toBe('/basepath');
161-
expect(self.build?.[0]).toMatch(/\/basepath\/_app\/immutable\/bundle\.[\w-]+\.js/);
162-
expect(self.image_src).toMatch(/\/basepath\/_app\/immutable\/assets\/image\.[\w-]+\.jpg/);
163-
});
123+
test.skip(({ javaScriptEnabled }) => !javaScriptEnabled);
164124

165125
test('does not register /basepath/service-worker.js', async ({ page }) => {
166126
await page.goto('/basepath');

0 commit comments

Comments
 (0)