Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions documentation/docs/98-reference/20-$app-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: $app/integrity
---

> MODULE: $app/integrity
1 change: 1 addition & 0 deletions packages/kit/scripts/generate-dts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -109,6 +112,7 @@ const get_defaults = (prefix = '') => ({
serviceWorker: {
register: true
},
subresourceIntegrity: false,
typescript: {},
paths: {
base: '',
Expand Down
8 changes: 8 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}),
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link>` and `<script>` tags.
* @default false
*/
subresourceIntegrity?: false | 'sha256' | 'sha384' | 'sha512';
typescript?: {
/**
* A function that allows you to edit the generated `tsconfig.json`. You can mutate the config (recommended) or return a new one.
Expand Down
19 changes: 19 additions & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
Expand Down Expand Up @@ -1269,6 +1270,24 @@ async function kit({ svelte_config }) {
}
}

// Compute SRI integrity hashes from the files on disk, to ensure
// the hash matches the content the browser will actually receive
if (build_data.client && svelte_config.kit.subresourceIntegrity) {
const algorithm = svelte_config.kit.subresourceIntegrity;
/** @type {Record<string, string>} */
const integrity = {};

for (const chunk of /** @type {import('vite').Rollup.OutputBundle[string][]} */ (
client_chunks
)) {
const content = fs.readFileSync(`${out}/client/${chunk.fileName}`);
const hash = createHash(algorithm).update(content).digest('base64');
integrity[chunk.fileName] = `${algorithm}-${hash}`;
}

build_data.client.integrity = integrity;
}

// regenerate manifest now that we have client entry...
fs.writeFileSync(
manifest_path,
Expand Down
37 changes: 37 additions & 0 deletions packages/kit/src/runtime/app/integrity/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { BROWSER } from 'esm-env';
import { manifest } from '__sveltekit/server';
import { initial_base } from '$app/paths/internal/server';

/**
* @param {string} url
* @returns {string | undefined}
*/
function server_integrity(url) {
const integrity_map = manifest?._.client.integrity;
if (!integrity_map) return undefined;

// Integrity map keys are like "_app/immutable/assets/foo.abc123.js"
// URLs from ?url imports are absolute: "/_app/immutable/assets/foo.abc123.js"
// or with base: "/my-base/_app/immutable/assets/foo.abc123.js"
//
// We use initial_base (not base) because base can be overridden to a relative
// path during rendering when paths.relative is true, while ?url imports are
// always absolute.
const prefix = (initial_base || '') + '/';
if (url.startsWith(prefix)) {
return integrity_map[url.slice(prefix.length)];
}

return undefined;
}

/**
* Look up the SRI integrity hash for a Vite-processed asset URL.
* Returns the integrity string (e.g. `"sha384-..."`) during SSR, or `undefined` on the client / in dev.
* @param {string} url
* @returns {string | undefined}
*/
export function integrity(url) {
if (BROWSER) return undefined;
return server_integrity(url);
}
20 changes: 20 additions & 0 deletions packages/kit/src/runtime/app/integrity/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Look up the SRI integrity hash for a Vite-processed asset URL.
* Returns the integrity string (e.g. `"sha384-..."`) during SSR when
* [`subresourceIntegrity`](https://svelte.dev/docs/kit/configuration#subresourceIntegrity) is enabled,
* or `undefined` on the client and in dev.
*
* ```svelte
* <script>
* import scriptUrl from "./my-script.js?url";
* import { integrity } from '$app/integrity';
* </script>
*
* <svelte:head>
* <script src="{scriptUrl}" type="module" integrity={integrity(scriptUrl)} crossorigin="anonymous"></script>
* </svelte:head>
* ```
* @param url The asset URL (e.g. from a `?url` import)
* @since 2.54.0
*/
export function integrity(url: string): string | undefined;
40 changes: 29 additions & 11 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down Expand Up @@ -712,11 +729,12 @@ class Head {
this.#stylesheet_links.push(`<link href="${href}" ${attributes.join(' ')}>`);
}

/** @param {string} href */
add_script_preload(href) {
this.#script_preloads.push(
`<link rel="preload" as="script" crossorigin="anonymous" href="${href}">`
);
/**
* @param {string} href
* @param {string[]} attributes
*/
add_script_preload(href, attributes) {
this.#script_preloads.push(`<link href="${href}" ${attributes.join(' ')}>`);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** Only set in case of `bundleStrategy === 'inline'`. */
inline?: {
script: string;
Expand Down
13 changes: 13 additions & 0 deletions packages/kit/test/apps/options/source/pages/integrity/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import { integrity } from '$app/integrity';
import bootstrapUrl from './bootstrap.js?url';

const hash = integrity(bootstrapUrl);
</script>

<svelte:head>
<script src={bootstrapUrl} type="module" integrity={hash} crossorigin="anonymous"></script>
</svelte:head>

<p id="url">{bootstrapUrl}</p>
<p id="hash">{hash ?? ''}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('bootstrap');
1 change: 1 addition & 0 deletions packages/kit/test/apps/options/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions packages/kit/test/apps/options/test/test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -62,6 +63,117 @@
});
});

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(/<link[^>]+>/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 <link rel="stylesheet"> rather than being inlined
const response = await request.get('/path-base/base');
const html = await response.text();

const link_tags = html.match(/<link[^>]+>/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(/<link[^>]+>/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(/<script[^>]*>([\s\S]*?)<\/script>/);
expect(script_match).not.toBeNull();
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(/<link[^>]+>/g) ?? [];
const links_with_integrity = link_tags
.filter((tag) => tag.includes('integrity='))
.map((tag) => {
const href = tag.match(/href="([^"]+)"/)?.[1];
const integrity = tag.match(/integrity="([^"]+)"/)?.[1];
return { href, integrity };
})
.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 id="hash">([^<]+)<\/p>/);
expect(hash_match).not.toBeNull();
expect(hash_match[1]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);

// The page renders a <script> tag with integrity attribute
const script_match = html.match(/<script[^>]*src="([^"]+)"[^>]*integrity="([^"]+)"[^>]*>/);
expect(script_match).not.toBeNull();
expect(script_match[1]).toContain('bootstrap');
expect(script_match[2]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);

// The hash in the page text and the script tag should match
expect(script_match[2]).toBe(hash_match[1]);
});
});

test.describe('Custom extensions', () => {
test('works with arbitrary extensions', async ({ page }) => {
await page.goto('/path-base/custom-extensions/');
Expand Down Expand Up @@ -188,7 +300,7 @@
expect(requests).toEqual([]);
});

test('accounts for base path when running data-sveltekit-preload-code', async ({

Check warning on line 303 in packages/kit/test/apps/options/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (24, ubuntu-latest, chromium, beta)

flaky test: accounts for base path when running data-sveltekit-preload-code

retries: 2

Check warning on line 303 in packages/kit/test/apps/options/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (22, ubuntu-latest, chromium, current)

flaky test: accounts for base path when running data-sveltekit-preload-code

retries: 2
page,
javaScriptEnabled
}) => {
Expand Down
Loading
Loading