;
/** Only set in case of `bundleStrategy === 'inline'`. */
inline?: {
script: string;
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 8190e62e9d79..f4e2463308b9 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,60 @@ 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}`);
+ }
+ });
+});
+
+test.describe('Integrity-Policy', () => {
+ // server-side route resolution `import()`s a per-path `__route.js` that has no build-time hash
+ test.skip(
+ ({ javaScriptEnabled }) =>
+ !javaScriptEnabled || !!process.env.DEV || process.env.ROUTER_RESOLUTION === 'server'
+ );
+
+ 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/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/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(/