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..32642d2a66a3 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -100,7 +100,10 @@ const get_defaults = (prefix = '') => ({ }, inlineStyleThreshold: 0, moduleExtensions: ['.js', '.ts'], - output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, + output: { + preloadStrategy: 'modulepreload', + bundleStrategy: 'split' + }, outDir: join(prefix, '.svelte-kit'), router: { type: 'pathname', @@ -109,6 +112,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..0edf7eae12f1 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -304,6 +304,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/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index b620044814d5..4975fbce2ec1 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -846,6 +846,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 ` + * + * + * + * + * ``` + * @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..9a35b410c5c2 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); + } } } } @@ -712,11 +729,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..6292e1c82ad9 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; 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..488da2dbeec4 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -29,6 +29,7 @@ const config = { output: { preloadStrategy: 'preload-mjs' }, + subresourceIntegrity: 'sha384', 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..99f5af5619d3 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import * as http from 'node:http'; import process from 'node:process'; import { expect } from '@playwright/test'; @@ -62,6 +63,117 @@ test.describe('CSP', () => { }); }); +test.describe('subresourceIntegrity', () => { + 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('integrity hashes match actual file content', async ({ request }) => { + const response = await request.get('/path-base/inline-style'); + const html = await response.text(); + + const link_tags = html.match(/]+>/g) ?? []; + const links_with_integrity = link_tags + .filter((tag) => tag.includes('integrity=')) + .map((tag) => { + const href = tag.match(/href="([^"]+)"/)?.[1]; + const integrity = tag.match(/integrity="([^"]+)"/)?.[1]; + return { href, integrity }; + }) + .filter((l) => l.href && l.integrity); + + expect(links_with_integrity.length).toBeGreaterThan(0); + + for (const { href, integrity } of links_with_integrity) { + // Resolve the relative href against the response URL to get a correct absolute path + const resolved = new URL(href, response.url()).pathname; + const res = await request.get(resolved); + expect(res.status(), `failed to fetch ${resolved}`).toBe(200); + const body = Buffer.from(await res.body()); + const [algo, expected_hash] = integrity.split('-', 2); + const actual_hash = createHash(algo).update(body).digest('base64'); + + expect(actual_hash, `integrity mismatch for ${resolved}`).toBe(expected_hash); + } + }); + + 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 + * + * + * + * + * ``` + * @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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 498ab85b8944..299910c259d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,27 @@ importers: specifier: 'catalog:' version: 6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + packages/kit/test/apps/options-4: + devDependencies: + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../.. + '@sveltejs/vite-plugin-svelte': + specifier: 'catalog:' + version: 6.2.4(svelte@5.53.5)(vite@6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) + svelte: + specifier: 'catalog:' + version: 5.53.5 + svelte-check: + specifier: 'catalog:' + version: 4.3.4(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.8.3) + typescript: + specifier: ^5.5.4 + version: 5.8.3 + vite: + specifier: 'catalog:' + version: 6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + packages/kit/test/apps/prerendered-app-error-pages: devDependencies: '@sveltejs/kit':