From abd24713f2a5fd57ef55fc3e1e37225cab1a7557 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Thu, 26 Feb 2026 11:28:54 -0700 Subject: [PATCH 1/6] feat: add support for Integrity-Policy & sri Signed-off-by: will Farrell --- .../docs/98-reference/20-$app-integrity.md | 5 ++ packages/kit/scripts/generate-dts.js | 1 + packages/kit/src/core/config/index.spec.js | 7 ++- packages/kit/src/core/config/options.js | 12 +++++ packages/kit/src/core/sync/write_server.js | 1 + packages/kit/src/exports/vite/index.js | 18 +++++++ .../kit/src/runtime/app/integrity/index.js | 37 ++++++++++++++ .../kit/src/runtime/app/integrity/types.d.ts | 20 ++++++++ .../kit/src/runtime/server/page/render.js | 49 ++++++++++++++----- packages/kit/src/types/internal.d.ts | 3 ++ packages/kit/types/index.d.ts | 27 ++++++++++ 11 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 documentation/docs/98-reference/20-$app-integrity.md create mode 100644 packages/kit/src/runtime/app/integrity/index.js create mode 100644 packages/kit/src/runtime/app/integrity/types.d.ts diff --git a/documentation/docs/98-reference/20-$app-integrity.md b/documentation/docs/98-reference/20-$app-integrity.md new file mode 100644 index 000000000000..e6f739f1ad41 --- /dev/null +++ b/documentation/docs/98-reference/20-$app-integrity.md @@ -0,0 +1,5 @@ +--- +title: $app/integrity +--- + +> MODULE: $app/integrity diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index b470d6d63494..ef9f3d8b4024 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -11,6 +11,7 @@ await createBundle({ '@sveltejs/kit/vite': 'src/exports/vite/index.js', '$app/environment': 'src/runtime/app/environment/types.d.ts', '$app/forms': 'src/runtime/app/forms.js', + '$app/integrity': 'src/runtime/app/integrity/types.d.ts', '$app/navigation': 'src/runtime/app/navigation.js', '$app/paths': 'src/runtime/app/paths/public.d.ts', '$app/server': 'src/runtime/app/server/index.js', diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 14ac3361701b..a9919e0be137 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -100,7 +100,11 @@ const get_defaults = (prefix = '') => ({ }, inlineStyleThreshold: 0, moduleExtensions: ['.js', '.ts'], - output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, + integrityPolicy: { endpoints: ['default'] }, + output: { + preloadStrategy: 'modulepreload', + bundleStrategy: 'split' + }, outDir: join(prefix, '.svelte-kit'), router: { type: 'pathname', @@ -109,6 +113,7 @@ const get_defaults = (prefix = '') => ({ serviceWorker: { register: true }, + subresourceIntegrity: false, typescript: {}, paths: { base: '', diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index d16ba4253582..16e412e2637a 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -154,6 +154,10 @@ const options = object( inlineStyleThreshold: number(0), + integrityPolicy: object({ + endpoints: string_array(['default']) + }), + moduleExtensions: string_array(['.js', '.ts']), outDir: string('.svelte-kit'), @@ -304,6 +308,14 @@ const options = object( files: fun((filename) => !/\.DS_Store/.test(filename)) }), + subresourceIntegrity: validate(false, (input, keypath) => { + if (input === false) return false; + if (!['sha256', 'sha384', 'sha512'].includes(input)) { + throw new Error(`${keypath} should be false, "sha256", "sha384" or "sha512"`); + } + return input; + }), + typescript: object({ config: fun((config) => config) }), diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index ea7d9dfe0c99..f0b6e460b825 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -47,6 +47,7 @@ export const options = { hash_routing: ${s(config.kit.router.type === 'hash')}, hooks: null, // added lazily, via \`get_hooks\` preload_strategy: ${s(config.kit.output.preloadStrategy)}, + integrity_policy_endpoints: ${s(config.kit.integrityPolicy.endpoints)}, root, service_worker: ${has_service_worker}, service_worker_options: ${config.kit.serviceWorker.register ? s(config.kit.serviceWorker.options) : 'null'}, diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 72db04111e56..8d995cba8ec0 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -1269,6 +1270,23 @@ async function kit({ svelte_config }) { } } + // Compute SRI integrity hashes + if (build_data.client && svelte_config.kit.subresourceIntegrity) { + const algorithm = svelte_config.kit.subresourceIntegrity; + /** @type {Record} */ + const integrity = {}; + + for (const chunk of /** @type {import('vite').Rollup.OutputBundle[string][]} */ ( + client_chunks + )) { + const content = chunk.type === 'chunk' ? chunk.code : chunk.source; + const hash = createHash(algorithm).update(content).digest('base64'); + integrity[chunk.fileName] = `${algorithm}-${hash}`; + } + + build_data.client.integrity = integrity; + } + // regenerate manifest now that we have client entry... fs.writeFileSync( manifest_path, diff --git a/packages/kit/src/runtime/app/integrity/index.js b/packages/kit/src/runtime/app/integrity/index.js new file mode 100644 index 000000000000..f15d8533e6a4 --- /dev/null +++ b/packages/kit/src/runtime/app/integrity/index.js @@ -0,0 +1,37 @@ +import { BROWSER } from 'esm-env'; +import { manifest } from '__sveltekit/server'; +import { initial_base } from '$app/paths/internal/server'; + +/** + * @param {string} url + * @returns {string | undefined} + */ +function server_integrity(url) { + const integrity_map = manifest?._.client.integrity; + if (!integrity_map) return undefined; + + // Integrity map keys are like "_app/immutable/assets/foo.abc123.js" + // URLs from ?url imports are absolute: "/_app/immutable/assets/foo.abc123.js" + // or with base: "/my-base/_app/immutable/assets/foo.abc123.js" + // + // We use initial_base (not base) because base can be overridden to a relative + // path during rendering when paths.relative is true, while ?url imports are + // always absolute. + const prefix = (initial_base || '') + '/'; + if (url.startsWith(prefix)) { + return integrity_map[url.slice(prefix.length)]; + } + + return undefined; +} + +/** + * Look up the SRI integrity hash for a Vite-processed asset URL. + * Returns the integrity string (e.g. `"sha384-..."`) during SSR, or `undefined` on the client / in dev. + * @param {string} url + * @returns {string | undefined} + */ +export function integrity(url) { + if (BROWSER) return undefined; + return server_integrity(url); +} diff --git a/packages/kit/src/runtime/app/integrity/types.d.ts b/packages/kit/src/runtime/app/integrity/types.d.ts new file mode 100644 index 000000000000..8113311ab0f0 --- /dev/null +++ b/packages/kit/src/runtime/app/integrity/types.d.ts @@ -0,0 +1,20 @@ +/** + * Look up the SRI integrity hash for a Vite-processed asset URL. + * Returns the integrity string (e.g. `"sha384-..."`) during SSR when + * [`subresourceIntegrity`](https://svelte.dev/docs/kit/configuration#subresourceIntegrity) is enabled, + * or `undefined` on the client and in dev. + * + * ```svelte + * + * + * + * + * + * ``` + * @param url The asset URL (e.g. from a `?url` import) + * @since 2.54.0 + */ +export function integrity(url: string): string | undefined; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 7648a757e02c..e364e98ea816 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -69,6 +69,7 @@ export async function render_response({ } const { client } = manifest._; + const integrity_map = client.integrity; const modulepreloads = new Set(client.imports); const stylesheets = new Set(client.stylesheets); @@ -301,6 +302,10 @@ export async function render_response({ // include them in disabled state so that Vite can detect them and doesn't try to add them attributes.push('disabled', 'media="(max-width: 0)"'); } else { + const integrity = integrity_map?.[dep]; + if (integrity) { + attributes.push(`integrity="${integrity}"`, 'crossorigin="anonymous"'); + } if (resolve_opts.preload({ type: 'css', path })) { link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`); } @@ -342,18 +347,30 @@ export async function render_response({ } if (!client.inline) { - const included_modulepreloads = Array.from(modulepreloads, (dep) => prefixed(dep)).filter( - (path) => resolve_opts.preload({ type: 'js', path }) - ); + for (const dep of modulepreloads) { + const path = prefixed(dep); + if (!resolve_opts.preload({ type: 'js', path })) continue; + + const integrity = integrity_map?.[dep]; - for (const path of included_modulepreloads) { // see the kit.output.preloadStrategy option for details on why we have multiple options here link_headers.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`); if (options.preload_strategy !== 'modulepreload') { - head.add_script_preload(path); + const attrs = ['rel="preload"', 'as="script"', 'crossorigin="anonymous"']; + if (integrity) { + attrs.push(`integrity="${integrity}"`); + } + head.add_script_preload(path, attrs); } else { - head.add_link_tag(path, ['rel="modulepreload"']); + const attrs = ['rel="modulepreload"']; + if (integrity) { + // Must emit HTML tag (not just Link header) for SRI to work + attrs.push(`integrity="${integrity}"`, 'crossorigin="anonymous"'); + head.add_script_preload(path, attrs); + } else { + head.add_link_tag(path, attrs); + } } } } @@ -598,6 +615,15 @@ export async function render_response({ if (link_headers.size) { headers.set('link', Array.from(link_headers).join(', ')); } + + if (integrity_map) { + const destinations = 'script style'; + const endpoints = options.integrity_policy_endpoints.join(' '); + headers.set( + 'integrity-policy', + `blocked-destinations=(${destinations}),endpoints=(${endpoints})` + ); + } } const html = options.templates.app({ @@ -712,11 +738,12 @@ class Head { this.#stylesheet_links.push(``); } - /** @param {string} href */ - add_script_preload(href) { - this.#script_preloads.push( - `` - ); + /** + * @param {string} href + * @param {string[]} attributes + */ + add_script_preload(href, attributes) { + this.#script_preloads.push(``); } /** diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 0be5b25d7655..991b0ee21e81 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -100,6 +100,8 @@ export interface BuildData { stylesheets: string[]; fonts: string[]; uses_env_dynamic_public: boolean; + /** Maps asset file paths to SRI integrity strings (e.g. "sha384-..."). Only set when `subresourceIntegrity` is enabled. */ + integrity?: Record; /** Only set in case of `bundleStrategy === 'inline'`. */ inline?: { script: string; @@ -478,6 +480,7 @@ export interface SSROptions { }): string; error(values: { message: string; status: number }): string; }; + integrity_policy_endpoints: string[]; version_hash: string; } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 7d7b33a81863..c9459c2b5189 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2451,6 +2451,8 @@ declare module '@sveltejs/kit' { stylesheets: string[]; fonts: string[]; uses_env_dynamic_public: boolean; + /** Maps asset file paths to SRI integrity strings (e.g. "sha384-..."). Only set when `subresourceIntegrity` is enabled. */ + integrity?: Record; /** Only set in case of `bundleStrategy === 'inline'`. */ inline?: { script: string; @@ -2998,6 +3000,31 @@ declare module '$app/forms' { export {}; } +declare module '$app/integrity' { + /** + * Look up the SRI integrity hash for a Vite-processed asset URL. + * Returns the integrity string (e.g. `"sha384-..."`) during SSR when + * [`subresourceIntegrity`](https://svelte.dev/docs/kit/configuration#subresourceIntegrity) is enabled, + * or `undefined` on the client and in dev. + * + * ```svelte + * + * + * + * + * + * ``` + * @param url The asset URL (e.g. from a `?url` import) + * @since 2.54.0 + */ + export function integrity(url: string): string | undefined; + + export {}; +} + declare module '$app/navigation' { /** * A lifecycle function that runs the supplied `callback` when the current component mounts, and also whenever we navigate to a URL. From 28d2e2f5bcf8fa5e72bb3f48353413dc512c6480 Mon Sep 17 00:00:00 2001 From: will Farrell Date: Thu, 26 Feb 2026 11:47:54 -0700 Subject: [PATCH 2/6] test: add in proper test and types Signed-off-by: will Farrell --- packages/kit/src/exports/public.d.ts | 15 ++++++ packages/kit/test/apps/options-4/package.json | 22 +++++++++ .../test/apps/options-4/playwright.config.js | 1 + packages/kit/test/apps/options-4/src/app.html | 11 +++++ .../apps/options-4/src/routes/+layout.svelte | 7 +++ .../apps/options-4/src/routes/+page.svelte | 1 + .../options-4/src/routes/styled/+page.svelte | 7 +++ .../kit/test/apps/options-4/svelte.config.js | 11 +++++ packages/kit/test/apps/options-4/test/test.js | 49 +++++++++++++++++++ .../kit/test/apps/options-4/tsconfig.json | 8 +++ .../kit/test/apps/options-4/vite.config.js | 18 +++++++ packages/kit/types/index.d.ts | 15 ++++++ pnpm-lock.yaml | 21 ++++++++ 13 files changed, 186 insertions(+) create mode 100644 packages/kit/test/apps/options-4/package.json create mode 100644 packages/kit/test/apps/options-4/playwright.config.js create mode 100644 packages/kit/test/apps/options-4/src/app.html create mode 100644 packages/kit/test/apps/options-4/src/routes/+layout.svelte create mode 100644 packages/kit/test/apps/options-4/src/routes/+page.svelte create mode 100644 packages/kit/test/apps/options-4/src/routes/styled/+page.svelte create mode 100644 packages/kit/test/apps/options-4/svelte.config.js create mode 100644 packages/kit/test/apps/options-4/test/test.js create mode 100644 packages/kit/test/apps/options-4/tsconfig.json create mode 100644 packages/kit/test/apps/options-4/vite.config.js diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index b620044814d5..59ba19b62771 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -597,6 +597,16 @@ export interface KitConfig { * @default 0 */ inlineStyleThreshold?: number; + /** + * Configuration for the [`Integrity-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Integrity-Policy) response header, which is set when `subresourceIntegrity` is enabled. + */ + integrityPolicy?: { + /** + * The reporting endpoints to include in the `Integrity-Policy` header. + * @default ["default"] + */ + endpoints?: string[]; + }; /** * An array of file extensions that SvelteKit will treat as modules. Files with extensions that match neither `config.extensions` nor `config.kit.moduleExtensions` will be ignored by the router. * @default [".js", ".ts"] @@ -846,6 +856,11 @@ export interface KitConfig { register?: false; } ); + /** + * Enable [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) (SRI) hash generation for scripts and stylesheets. When set to a hash algorithm, SvelteKit will compute integrity hashes for all client assets at build time and add `integrity` and `crossorigin` attributes to `` and ` + + diff --git a/packages/kit/test/apps/options-4/src/routes/+page.svelte b/packages/kit/test/apps/options-4/src/routes/+page.svelte new file mode 100644 index 000000000000..d5002f962ef9 --- /dev/null +++ b/packages/kit/test/apps/options-4/src/routes/+page.svelte @@ -0,0 +1 @@ +

SRI test

diff --git a/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte b/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte new file mode 100644 index 000000000000..d7eadd1e22d5 --- /dev/null +++ b/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte @@ -0,0 +1,7 @@ +

Styled page

+ + diff --git a/packages/kit/test/apps/options-4/svelte.config.js b/packages/kit/test/apps/options-4/svelte.config.js new file mode 100644 index 000000000000..1ec96b3cbc8d --- /dev/null +++ b/packages/kit/test/apps/options-4/svelte.config.js @@ -0,0 +1,11 @@ +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + subresourceIntegrity: 'sha384', + integrityPolicy: { + endpoints: ['default'] + } + } +}; + +export default config; diff --git a/packages/kit/test/apps/options-4/test/test.js b/packages/kit/test/apps/options-4/test/test.js new file mode 100644 index 000000000000..0df4cdcdbe2a --- /dev/null +++ b/packages/kit/test/apps/options-4/test/test.js @@ -0,0 +1,49 @@ +import process from 'node:process'; +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('subresourceIntegrity', () => { + test.skip(() => !!process.env.DEV); + + test('adds integrity attribute to script preloads', async ({ request }) => { + const response = await request.get('/'); + const html = await response.text(); + + // All preloaded scripts should have integrity attributes + const link_tags = html.match(/]+>/g) ?? []; + const script_links = link_tags.filter( + (tag) => tag.includes('as="script"') || tag.includes('rel="modulepreload"') + ); + + expect(script_links.length).toBeGreaterThan(0); + + for (const tag of script_links) { + expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); + expect(tag).toContain('crossorigin="anonymous"'); + } + }); + + test('adds integrity attribute to stylesheet links', async ({ request }) => { + const response = await request.get('/styled'); + const html = await response.text(); + + const link_tags = html.match(/]+>/g) ?? []; + const stylesheet_links = link_tags.filter((tag) => tag.includes('rel="stylesheet"')); + + expect(stylesheet_links.length).toBeGreaterThan(0); + + for (const tag of stylesheet_links) { + expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); + expect(tag).toContain('crossorigin="anonymous"'); + } + }); + + test('sets integrity-policy response header', async ({ request }) => { + const response = await request.get('/'); + const header = response.headers()['integrity-policy']; + + expect(header).toBe('blocked-destinations=(script style),endpoints=(default)'); + }); +}); diff --git a/packages/kit/test/apps/options-4/tsconfig.json b/packages/kit/test/apps/options-4/tsconfig.json new file mode 100644 index 000000000000..b1096bf168cd --- /dev/null +++ b/packages/kit/test/apps/options-4/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/apps/options-4/vite.config.js b/packages/kit/test/apps/options-4/vite.config.js new file mode 100644 index 000000000000..69200cdb7cd8 --- /dev/null +++ b/packages/kit/test/apps/options-4/vite.config.js @@ -0,0 +1,18 @@ +import * as path from 'node:path'; +import { sveltekit } from '@sveltejs/kit/vite'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + clearScreen: false, + plugins: [sveltekit()], + server: { + fs: { + allow: [path.resolve('../../../src')] + } + } +}; + +export default config; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c9459c2b5189..3fd2fbb16332 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -571,6 +571,16 @@ declare module '@sveltejs/kit' { * @default 0 */ inlineStyleThreshold?: number; + /** + * Configuration for the [`Integrity-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Integrity-Policy) response header, which is set when `subresourceIntegrity` is enabled. + */ + integrityPolicy?: { + /** + * The reporting endpoints to include in the `Integrity-Policy` header. + * @default ["default"] + */ + endpoints?: string[]; + }; /** * An array of file extensions that SvelteKit will treat as modules. Files with extensions that match neither `config.extensions` nor `config.kit.moduleExtensions` will be ignored by the router. * @default [".js", ".ts"] @@ -820,6 +830,11 @@ declare module '@sveltejs/kit' { register?: false; } ); + /** + * Enable [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) (SRI) hash generation for scripts and stylesheets. When set to a hash algorithm, SvelteKit will compute integrity hashes for all client assets at build time and add `integrity` and `crossorigin` attributes to `` and ` - - diff --git a/packages/kit/test/apps/options-4/src/routes/+page.svelte b/packages/kit/test/apps/options-4/src/routes/+page.svelte deleted file mode 100644 index d5002f962ef9..000000000000 --- a/packages/kit/test/apps/options-4/src/routes/+page.svelte +++ /dev/null @@ -1 +0,0 @@ -

SRI test

diff --git a/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte b/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte deleted file mode 100644 index d7eadd1e22d5..000000000000 --- a/packages/kit/test/apps/options-4/src/routes/styled/+page.svelte +++ /dev/null @@ -1,7 +0,0 @@ -

Styled page

- - diff --git a/packages/kit/test/apps/options-4/svelte.config.js b/packages/kit/test/apps/options-4/svelte.config.js deleted file mode 100644 index 1ec96b3cbc8d..000000000000 --- a/packages/kit/test/apps/options-4/svelte.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('@sveltejs/kit').Config} */ -const config = { - kit: { - subresourceIntegrity: 'sha384', - integrityPolicy: { - endpoints: ['default'] - } - } -}; - -export default config; diff --git a/packages/kit/test/apps/options-4/test/test.js b/packages/kit/test/apps/options-4/test/test.js deleted file mode 100644 index 0df4cdcdbe2a..000000000000 --- a/packages/kit/test/apps/options-4/test/test.js +++ /dev/null @@ -1,49 +0,0 @@ -import process from 'node:process'; -import { expect } from '@playwright/test'; -import { test } from '../../../utils.js'; - -test.describe.configure({ mode: 'parallel' }); - -test.describe('subresourceIntegrity', () => { - test.skip(() => !!process.env.DEV); - - test('adds integrity attribute to script preloads', async ({ request }) => { - const response = await request.get('/'); - const html = await response.text(); - - // All preloaded scripts should have integrity attributes - const link_tags = html.match(/]+>/g) ?? []; - const script_links = link_tags.filter( - (tag) => tag.includes('as="script"') || tag.includes('rel="modulepreload"') - ); - - expect(script_links.length).toBeGreaterThan(0); - - for (const tag of script_links) { - expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); - expect(tag).toContain('crossorigin="anonymous"'); - } - }); - - test('adds integrity attribute to stylesheet links', async ({ request }) => { - const response = await request.get('/styled'); - const html = await response.text(); - - const link_tags = html.match(/]+>/g) ?? []; - const stylesheet_links = link_tags.filter((tag) => tag.includes('rel="stylesheet"')); - - expect(stylesheet_links.length).toBeGreaterThan(0); - - for (const tag of stylesheet_links) { - expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); - expect(tag).toContain('crossorigin="anonymous"'); - } - }); - - test('sets integrity-policy response header', async ({ request }) => { - const response = await request.get('/'); - const header = response.headers()['integrity-policy']; - - expect(header).toBe('blocked-destinations=(script style),endpoints=(default)'); - }); -}); diff --git a/packages/kit/test/apps/options-4/tsconfig.json b/packages/kit/test/apps/options-4/tsconfig.json deleted file mode 100644 index b1096bf168cd..000000000000 --- a/packages/kit/test/apps/options-4/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "noEmit": true - }, - "extends": "./.svelte-kit/tsconfig.json" -} diff --git a/packages/kit/test/apps/options-4/vite.config.js b/packages/kit/test/apps/options-4/vite.config.js deleted file mode 100644 index 69200cdb7cd8..000000000000 --- a/packages/kit/test/apps/options-4/vite.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import * as path from 'node:path'; -import { sveltekit } from '@sveltejs/kit/vite'; - -/** @type {import('vite').UserConfig} */ -const config = { - build: { - minify: false - }, - clearScreen: false, - plugins: [sveltekit()], - server: { - fs: { - allow: [path.resolve('../../../src')] - } - } -}; - -export default config; diff --git a/packages/kit/test/apps/options/source/pages/integrity/+page.svelte b/packages/kit/test/apps/options/source/pages/integrity/+page.svelte new file mode 100644 index 000000000000..1e2689421b85 --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/integrity/+page.svelte @@ -0,0 +1,13 @@ + + + + + + +

{bootstrapUrl}

+

{hash ?? ''}

diff --git a/packages/kit/test/apps/options/source/pages/integrity/bootstrap.js b/packages/kit/test/apps/options/source/pages/integrity/bootstrap.js new file mode 100644 index 000000000000..85f60bd716b2 --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/integrity/bootstrap.js @@ -0,0 +1 @@ +console.log('bootstrap'); diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js index 4852cc71596d..dc4f32cc4949 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -29,6 +29,10 @@ const config = { output: { preloadStrategy: 'preload-mjs' }, + subresourceIntegrity: 'sha384', + integrityPolicy: { + endpoints: ['other'] + }, paths: { base: '/path-base', // @ts-expect-error our env var string can't match the https template literal diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index 87a6c02d7534..1fb9d222a3da 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -62,6 +62,95 @@ test.describe('CSP', () => { }); }); +test.describe('subresourceIntegrity and integrityPolicy', () => { + test.skip(() => !!process.env.DEV); + + test('adds integrity attribute to script preloads', async ({ request }) => { + const response = await request.get('/path-base/inline-style'); + const html = await response.text(); + + const link_tags = html.match(/]+>/g) ?? []; + const script_links = link_tags.filter((tag) => tag.includes('as="script"')); + + expect(script_links.length).toBeGreaterThan(0); + + for (const tag of script_links) { + expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); + expect(tag).toContain('crossorigin="anonymous"'); + } + }); + + test('adds integrity attribute to stylesheet links', async ({ request }) => { + // /base has a CSS file (SvelteLogo) that exceeds inlineStyleThreshold, + // so it renders as a rather than being inlined + const response = await request.get('/path-base/base'); + const html = await response.text(); + + const link_tags = html.match(/]+>/g) ?? []; + const style_links = link_tags.filter( + (tag) => tag.includes('rel="stylesheet"') && !tag.includes('disabled') + ); + + expect(style_links.length).toBeGreaterThan(0); + + for (const tag of style_links) { + expect(tag).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/); + expect(tag).toContain('crossorigin="anonymous"'); + } + }); + + test('entry script imports are covered by integrity preloads', async ({ request }) => { + const response = await request.get('/path-base/inline-style'); + const html = await response.text(); + + // Extract URLs from integrity-protected preload links + const link_tags = html.match(/]+>/g) ?? []; + const preloaded_urls = new Set( + link_tags + .filter((tag) => tag.includes('integrity=')) + .map((tag) => tag.match(/href="([^"]+)"/)?.[1]) + .filter(Boolean) + ); + + // Extract URLs from the inline boot script's import() calls + const script_match = html.match(/]*>([\s\S]*?)<\/script>/); + expect(script_match).not.toBeNull(); + const import_urls = [...script_match[1].matchAll(/import\("([^"]+)"\)/g)].map((m) => m[1]); + expect(import_urls.length).toBeGreaterThan(0); + + // Every import() URL should have a corresponding integrity preload + for (const url of import_urls) { + expect(preloaded_urls).toContain(url); + } + }); + + test('$app/integrity returns hash for ?url imports', async ({ request }) => { + const response = await request.get('/path-base/integrity'); + const html = await response.text(); + + // The page renders the hash from $app/integrity into #hash + const hash_match = html.match(/

([^<]+)<\/p>/); + expect(hash_match).not.toBeNull(); + expect(hash_match[1]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/); + + // The page renders a