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