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(/
+ *
+ *
+ *
+ *
+ * ```
+ * @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':