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 763240d9871c..bab8f61b0f6f 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -13,6 +13,7 @@ await createBundle({ '$app/env': 'src/runtime/app/env/types.d.ts', '$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 7bc8af89bc79..4a57f935fa7b 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -102,7 +102,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', @@ -112,6 +115,7 @@ const get_defaults = (prefix = '') => ({ options: undefined, 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 f449898e53f0..bf61f3a663f1 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -327,6 +327,14 @@ export 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 80fd1b82b4d6..6b21bc38807e 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -875,6 +875,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 ca7e044248e5..db0fb30f3bff 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -78,6 +78,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); @@ -337,6 +338,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`); } @@ -384,18 +389,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); + } } } } @@ -734,11 +751,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 bbf3e5c3a4e7..86244a96d888 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -106,6 +106,8 @@ export interface BuildData { * Whether the client uses public dynamic env vars — `$env/dynamic/public` or `$app/env/public`. */ 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 271373259438..a0a073126b04 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -30,6 +30,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 a7356b5fec04..1b4a9c29c995 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'; @@ -74,6 +75,116 @@ 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>/); + if (!script_match) throw new Error('no inline boot script found'); + 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) => ({ + href: tag.match(/href="([^"]+)"/)?.[1] ?? '', + integrity: tag.match(/integrity="([^"]+)"/)?.[1] ?? '' + })) + .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>/); + if (!hash_match) throw new Error('no #hash element found'); + 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.