From 65b113a62c780327ffc0e63c029a9e1e4d9f46ac Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 10 Oct 2026 12:51:53 -0600 Subject: [PATCH 1/3] feat: add support for same-origin SRI allowing use of strict Integrity-Policy Signed-off-by: will Farrell --- .changeset/sri-subresource-integrity.md | 5 +++ packages/kit/src/core/config/index.spec.js | 1 + packages/kit/src/core/config/options.js | 8 +++++ packages/kit/src/exports/vite/build/index.js | 20 ++++++++++++ packages/kit/src/exports/vite/public.d.ts | 5 +++ .../kit/src/runtime/server/page/render.js | 19 +++++++++++- packages/kit/src/types/internal.d.ts | 2 ++ packages/kit/test/apps/options/test/test.js | 31 +++++++++++++++++++ .../test/apps/options/vite.custom.config.js | 1 + packages/kit/types/index.d.ts | 5 +++ 10 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .changeset/sri-subresource-integrity.md diff --git a/.changeset/sri-subresource-integrity.md b/.changeset/sri-subresource-integrity.md new file mode 100644 index 000000000000..0ae4462a9ada --- /dev/null +++ b/.changeset/sri-subresource-integrity.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `subresourceIntegrity` option to emit SRI hashes on generated `` tags diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 7dbe755f824e..a651e05e9965 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -122,6 +122,7 @@ const get_defaults = (prefix = '') => ({ options: undefined, register: true }, + subresourceIntegrity: false, tracing: { server: false }, typescript: {}, paths: { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a922f6304d69..80a45fdf98af 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -264,6 +264,14 @@ const options = { options: validate(undefined, object({}, true)) }), + 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; + }), + tracing: object({ server: boolean(false) }), diff --git a/packages/kit/src/exports/vite/build/index.js b/packages/kit/src/exports/vite/build/index.js index 921c89898313..bdc1ec9e67d5 100644 --- a/packages/kit/src/exports/vite/build/index.js +++ b/packages/kit/src/exports/vite/build/index.js @@ -2,6 +2,7 @@ /** @import { EnvVarConfig } from '@sveltejs/kit/env' */ /** @import { BuildData, ManifestData, Prerendered, ServerMetadata, RemoteChunk, ValidatedConfig } from 'types' */ /** @import { Manifest, Plugin, ResolvedConfig, Rolldown, UserConfig } from 'vite' */ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -762,6 +763,25 @@ export function plugin_compile( } } + // Compute SRI integrity hashes from the files on disk, so the hash always + // matches the bytes the browser actually receives + if (build_data.client && kit.subresourceIntegrity) { + const algorithm = kit.subresourceIntegrity; + /** @type {Record} */ + const integrity = {}; + + for (const chunk of client_chunks) { + const file = `${out}/client/${chunk.fileName}`; + // inlined chunks are deleted above, so they are never fetched + if (!fs.existsSync(file)) continue; + + const hash = createHash(algorithm).update(fs.readFileSync(file)).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/exports/vite/public.d.ts b/packages/kit/src/exports/vite/public.d.ts index 33e2bb2a18f8..5ceaed5be02d 100644 --- a/packages/kit/src/exports/vite/public.d.ts +++ b/packages/kit/src/exports/vite/public.d.ts @@ -508,6 +508,11 @@ export interface Config extends VitePluginSvelteOptions { */ 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 computes integrity hashes for client assets at build time and adds `integrity` and `crossorigin` attributes to the `` tags it emits. + * @default false + */ + subresourceIntegrity?: false | 'sha256' | 'sha384' | 'sha512'; /** * Options for enabling [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations. * @default { server: false } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0a8042eb03fa..02a3aeaf45f8 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -80,6 +80,7 @@ export async function render_response({ } const client = manifest.client; + const integrity_map = client?.integrity; const modulepreloads = new Set(client?.imports); const stylesheets = new Set(client?.stylesheets); @@ -319,6 +320,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 (__SVELTEKIT_LINK_HEADER_PRELOAD__ && resolve_opts.preload({ type: 'css', path })) { link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`); } @@ -371,7 +376,19 @@ export async function render_response({ const path = prefixed(dep); if (resolve_opts.preload({ type: 'js', path })) { - add_preload(path, ['rel="modulepreload"']); + const integrity = integrity_map?.[dep]; + + if (integrity) { + // SRI only takes effect on the HTML tag — a `Link` header cannot carry + // `integrity`, so bypass `add_preload` and always emit the tag + head.add_link_tag(path, [ + 'rel="modulepreload"', + `integrity="${integrity}"`, + 'crossorigin="anonymous"' + ]); + } else { + add_preload(path, ['rel="modulepreload"']); + } } } } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 1d49f5c97775..e4ded81fcf91 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -121,6 +121,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/test/test.js b/packages/kit/test/apps/options/test/test.js index 8190e62e9d79..2f8dc9f300a9 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -1,4 +1,5 @@ import process from 'node:process'; +import { createHash } from 'node:crypto'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; @@ -313,3 +314,33 @@ test.describe('Async', () => { expect(logs).toEqual(['mounted', 'navigated']); }); }); + +test.describe('subresourceIntegrity', () => { + test('integrity hashes match the served assets', async ({ page, request }) => { + test.skip(!!process.env.DEV, 'SRI hashes are only generated at build time'); + + await page.goto('/path-base'); + + const links = await page.locator('link[integrity]').all(); + expect(links.length).toBeGreaterThan(0); + + for (const link of links) { + const href = await link.getAttribute('href'); + const integrity = await link.getAttribute('integrity'); + if (!href || !integrity) throw new Error('link is missing href or integrity'); + + expect(await link.getAttribute('crossorigin')).toBe('anonymous'); + + // `paths.relative` is enabled for this app, so hrefs are relative to the page + const response = await request.get(new URL(href, page.url()).href); + expect(response.status()).toBe(200); + + // the hash must match the bytes the browser actually receives, otherwise + // the browser rejects the asset + const hash = createHash('sha384') + .update(await response.body()) + .digest('base64'); + expect(integrity).toBe(`sha384-${hash}`); + } + }); +}); diff --git a/packages/kit/test/apps/options/vite.custom.config.js b/packages/kit/test/apps/options/vite.custom.config.js index 52121c3b4092..ab5e52b3cd42 100644 --- a/packages/kit/test/apps/options/vite.custom.config.js +++ b/packages/kit/test/apps/options/vite.custom.config.js @@ -16,6 +16,7 @@ const config = { async: true } }, + subresourceIntegrity: 'sha384', csp: { directives: { 'script-src': ['self'], diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 553c98623e96..31f6358212f7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2094,6 +2094,11 @@ declare module '@sveltejs/kit/vite' { */ 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 computes integrity hashes for client assets at build time and adds `integrity` and `crossorigin` attributes to the `` tags it emits. + * @default false + */ + subresourceIntegrity?: false | 'sha256' | 'sha384' | 'sha512'; /** * Options for enabling [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations. * @default { server: false } From a9868768f56be61b73f8b468102cc7560ed1f78c Mon Sep 17 00:00:00 2001 From: will Farrell Date: Sat, 10 Oct 2026 14:12:10 -0600 Subject: [PATCH 2/3] feat: add in support for import-map Signed-off-by: will Farrell --- packages/kit/src/exports/vite/build/index.js | 71 +++++++++++++++---- .../kit/src/runtime/server/page/render.js | 36 +++++++++- .../pages/integrity-policy/+layout.server.js | 5 ++ .../pages/integrity-policy/+page.svelte | 6 ++ .../pages/integrity-policy/b/+page.svelte | 6 ++ packages/kit/test/apps/options/test/test.js | 23 ++++++ .../src/routes/app-manifest/+page.svelte | 6 ++ .../prerendering/basics/test/tests.spec.js | 34 +++++++++ .../test/prerendering/basics/vite.config.js | 1 + 9 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 packages/kit/test/apps/options/source/pages/integrity-policy/+layout.server.js create mode 100644 packages/kit/test/apps/options/source/pages/integrity-policy/+page.svelte create mode 100644 packages/kit/test/apps/options/source/pages/integrity-policy/b/+page.svelte create mode 100644 packages/kit/test/prerendering/basics/src/routes/app-manifest/+page.svelte diff --git a/packages/kit/src/exports/vite/build/index.js b/packages/kit/src/exports/vite/build/index.js index bdc1ec9e67d5..a4f28ab9d4e1 100644 --- a/packages/kit/src/exports/vite/build/index.js +++ b/packages/kit/src/exports/vite/build/index.js @@ -766,20 +766,11 @@ export function plugin_compile( // Compute SRI integrity hashes from the files on disk, so the hash always // matches the bytes the browser actually receives if (build_data.client && kit.subresourceIntegrity) { - const algorithm = kit.subresourceIntegrity; - /** @type {Record} */ - const integrity = {}; - - for (const chunk of client_chunks) { - const file = `${out}/client/${chunk.fileName}`; - // inlined chunks are deleted above, so they are never fetched - if (!fs.existsSync(file)) continue; - - const hash = createHash(algorithm).update(fs.readFileSync(file)).digest('base64'); - integrity[chunk.fileName] = `${algorithm}-${hash}`; - } - - build_data.client.integrity = integrity; + build_data.client.integrity = compute_integrity( + `${out}/client`, + client_chunks.map((chunk) => chunk.fileName), + kit.subresourceIntegrity + ); } // regenerate manifest now that we have client entry... @@ -846,6 +837,36 @@ export function plugin_compile( replace_manifest_placeholder_strings(`${out}/server`, { prerendered: prerendered_paths }); replace_manifest_placeholder_strings(`${out}/client`, { prerendered: prerendered_paths }); + // the replacement above rewrites client chunks that import `$app/manifest`, so + // their SRI hashes are stale — recompute them, and update the pages that were + // prerendered with the old hashes + const old_integrity = build_data.client?.integrity; + if (build_data.client && old_integrity && kit.subresourceIntegrity) { + const integrity = compute_integrity( + `${out}/client`, + Object.keys(old_integrity), + kit.subresourceIntegrity + ); + build_data.client.integrity = integrity; + + const changed = Object.keys(integrity).filter( + (file) => integrity[file] !== old_integrity[file] + ); + + if (changed.length > 0 && fs.existsSync(`${out}/prerendered`)) { + for (const file of walk(`${out}/prerendered`)) { + if (!file.endsWith('.html')) continue; + + const file_path = `${out}/prerendered/${file}`; + let html = read(file_path); + for (const chunk of changed) { + html = html.replaceAll(old_integrity[chunk], integrity[chunk]); + } + fs.writeFileSync(file_path, html); + } + } + } + // For `inline` strategy, the entry file was deleted and read into // `build_data.client.inline.script` — replace the sentinel there too if (build_data.client?.inline?.script) { @@ -1109,6 +1130,28 @@ const replace_manifest_placeholder_variables = (chunks, output_dir, values) => { } }; +/** + * Computes SRI hashes from the files on disk, so a hash always matches the bytes the + * browser receives. Files that no longer exist (e.g. inlined chunks) are skipped. + * @param {string} dir + * @param {string[]} files + * @param {'sha256' | 'sha384' | 'sha512'} algorithm + */ +const compute_integrity = (dir, files, algorithm) => { + /** @type {Record} */ + const integrity = {}; + + for (const file of files) { + const file_path = `${dir}/${file}`; + if (!fs.existsSync(file_path)) continue; + + const hash = createHash(algorithm).update(fs.readFileSync(file_path)).digest('base64'); + integrity[file] = `${algorithm}-${hash}`; + } + + return integrity; +}; + /** * Replaces manifest sentinel strings in files on disk with real values. * This is used for values that weren't known when the first replacement diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 02a3aeaf45f8..9435513fbc81 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -323,8 +323,12 @@ export async function render_response({ const integrity = integrity_map?.[dep]; if (integrity) { attributes.push(`integrity="${integrity}"`, 'crossorigin="anonymous"'); - } - if (__SVELTEKIT_LINK_HEADER_PRELOAD__ && resolve_opts.preload({ type: 'css', path })) { + } else if ( + // a `Link` header cannot carry `integrity`, and the browser won't use a + // preload whose integrity doesn't match the stylesheet, so skip it + __SVELTEKIT_LINK_HEADER_PRELOAD__ && + resolve_opts.preload({ type: 'css', path }) + ) { link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`); } } @@ -360,6 +364,25 @@ export async function render_response({ if (page_config.csr && client) { const route = client.routes?.find((r) => r.id === event.route.id) ?? null; + if (integrity_map) { + // `import()` and Vite's preload helper can't carry an `integrity` attribute, so + // the browser looks up the integrity of every JS module in the import map instead + // ponytail: lists every client JS chunk on every page; scope it per route if the map gets big + /** @type {Record} */ + const integrity = {}; + for (const file in integrity_map) { + if (file.endsWith('.js')) integrity[prefixed(file)] = integrity_map[file]; + } + + const import_map = JSON.stringify({ integrity }); + await csp.add_script(import_map); + head.set_import_map( + `` + ); + } + // when serving a prerendered page in an app that uses runtime public env vars, we must // import the env.js module so that it evaluates before any user code can evaluate. // TODO revert to using top-level await once https://bugs.webkit.org/show_bug.cgi?id=242740 is fixed @@ -663,6 +686,8 @@ class Head { /** @type {string[]} */ #http_equiv = []; /** @type {string[]} */ + #import_map = []; + /** @type {string[]} */ #link_tags = []; /** @type {string[]} */ #style_tags = []; @@ -679,6 +704,8 @@ class Head { build() { return [ ...this.#http_equiv, + // must come before any module is loaded, so its integrity metadata applies to all of them + ...this.#import_map, ...this.#link_tags, this.#rendered, ...this.#style_tags, @@ -712,6 +739,11 @@ class Head { this.#link_tags.push(``); } + /** @param {string} tag */ + set_import_map(tag) { + this.#import_map = [tag]; + } + /** @param {string} tag */ add_http_equiv(tag) { this.#http_equiv.push(tag); diff --git a/packages/kit/test/apps/options/source/pages/integrity-policy/+layout.server.js b/packages/kit/test/apps/options/source/pages/integrity-policy/+layout.server.js new file mode 100644 index 000000000000..75f25a3ec1aa --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/integrity-policy/+layout.server.js @@ -0,0 +1,5 @@ +/** @type {import('./$types').LayoutServerLoad} */ +export function load({ setHeaders }) { + // browsers block any script loaded without integrity metadata + setHeaders({ 'integrity-policy': 'blocked-destinations=(script)' }); +} diff --git a/packages/kit/test/apps/options/source/pages/integrity-policy/+page.svelte b/packages/kit/test/apps/options/source/pages/integrity-policy/+page.svelte new file mode 100644 index 000000000000..b034692351d6 --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/integrity-policy/+page.svelte @@ -0,0 +1,6 @@ + + + +b diff --git a/packages/kit/test/apps/options/source/pages/integrity-policy/b/+page.svelte b/packages/kit/test/apps/options/source/pages/integrity-policy/b/+page.svelte new file mode 100644 index 000000000000..8f5119ebc3b4 --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/integrity-policy/b/+page.svelte @@ -0,0 +1,6 @@ + + +

b

+ diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index 2f8dc9f300a9..acc29b3b2171 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -344,3 +344,26 @@ test.describe('subresourceIntegrity', () => { } }); }); + +test.describe('Integrity-Policy', () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled || !!process.env.DEV); + + test('scripts load on first visit and client-side navigation', async ({ page }) => { + /** @type {string[]} */ + const blocked = []; + page.on('requestfailed', (request) => { + if (request.failure()?.errorText === 'integrity') blocked.push(request.url()); + }); + + await page.goto('/path-base/integrity-policy'); + await page.locator('button').click(); + await expect(page.locator('button')).toHaveText('clicks: 1'); + + await page.getByRole('link', { name: 'b', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'b', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'b clicks: 0' }).click(); + await expect(page.getByRole('button', { name: 'b clicks: 1' })).toBeVisible(); + + expect(blocked).toEqual([]); + }); +}); diff --git a/packages/kit/test/prerendering/basics/src/routes/app-manifest/+page.svelte b/packages/kit/test/prerendering/basics/src/routes/app-manifest/+page.svelte new file mode 100644 index 000000000000..2f26253121f3 --- /dev/null +++ b/packages/kit/test/prerendering/basics/src/routes/app-manifest/+page.svelte @@ -0,0 +1,6 @@ + + +
{JSON.stringify(prerendered)}
diff --git a/packages/kit/test/prerendering/basics/test/tests.spec.js b/packages/kit/test/prerendering/basics/test/tests.spec.js index f4b5e83a8a04..2ad27d432e69 100644 --- a/packages/kit/test/prerendering/basics/test/tests.spec.js +++ b/packages/kit/test/prerendering/basics/test/tests.spec.js @@ -1,4 +1,6 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assert, expect, test } from 'vitest'; import { replace_hydration_attrs } from '../../test-utils'; @@ -314,3 +316,35 @@ test('identifies missing ids', () => { contains: ['/missing-id#missing-id', 'id="missing-id"'] }); }); + +test('integrity hashes match the assets after prerendering rewrites them', () => { + // `/app-manifest` imports `prerendered` from `$app/manifest`, so its chunk is rewritten + // after prerendering — the hashes in every page must still match the final bytes + expect(read('app-manifest.html')).toContain('integrity='); + + let checked = 0; + + for (const file of fs.readdirSync(build, { recursive: true, encoding: 'utf-8' })) { + if (!file.endsWith('.html')) continue; + + const html = read(file); + + /** @type {Array<[string, string]>} */ + const entries = Array.from( + html.matchAll(/]*integrity="([^"]+)"/g), + (match) => [match[1], match[2]] + ); + + const import_map = html.match(/