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 .changeset/sri-subresource-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add `subresourceIntegrity` option to emit SRI hashes on generated `<link>` tags
1 change: 1 addition & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const get_defaults = (prefix = '') => ({
options: undefined,
register: true
},
subresourceIntegrity: false,
tracing: { server: false },
typescript: {},
paths: {
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 @@ -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)
}),
Expand Down
63 changes: 63 additions & 0 deletions packages/kit/src/exports/vite/build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -762,6 +763,16 @@ 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) {
build_data.client.integrity = compute_integrity(
`${out}/client`,
client_chunks.map((chunk) => chunk.fileName),
kit.subresourceIntegrity
);
}

// regenerate manifest now that we have client entry...
fs.writeFileSync(
manifest_path,
Expand Down Expand Up @@ -826,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) {
Expand Down Expand Up @@ -1089,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<string, string>} */
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
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/src/exports/vite/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link>` tags it emits. Module scripts get their hashes from an import map. Not compatible with a strict `Integrity-Policy` when [`router.resolution`](https://svelte.dev/docs/kit/configuration#router) is `'server'`, because the generated route-resolution modules have no build-time hash.
* @default false
*/
subresourceIntegrity?: false | 'sha256' | 'sha384' | 'sha512';
/**
* Options for enabling [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations.
* @default { server: false }
Expand Down
53 changes: 51 additions & 2 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -319,7 +320,15 @@ 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 {
if (__SVELTEKIT_LINK_HEADER_PRELOAD__ && resolve_opts.preload({ type: 'css', path })) {
const integrity = integrity_map?.[dep];
if (integrity) {
attributes.push(`integrity="${integrity}"`, 'crossorigin="anonymous"');
} 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`);
}
}
Expand Down Expand Up @@ -355,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<string, string>} */
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(
`<script type="importmap"${
csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''
}>${import_map}</script>`
);
}

// 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
Expand All @@ -371,7 +399,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"']);
}
}
}
}
Expand Down Expand Up @@ -646,6 +686,8 @@ class Head {
/** @type {string[]} */
#http_equiv = [];
/** @type {string[]} */
#import_map = [];
/** @type {string[]} */
#link_tags = [];
/** @type {string[]} */
#style_tags = [];
Expand All @@ -662,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,
Expand Down Expand Up @@ -695,6 +739,11 @@ class Head {
this.#link_tags.push(`<link href="${href}" ${attributes.join(' ')}>`);
}

/** @param {string} tag */
set_import_map(tag) {
this.#import_map = [tag];
}

/** @param {string} tag */
add_http_equiv(tag) {
this.#http_equiv.push(tag);
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 @@ -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<string, string>;
/** Only set in case of `bundleStrategy === 'inline'`. */
inline?: {
script: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -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)' });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
let count = $state(0);
</script>

<button onclick={() => (count += 1)}>clicks: {count}</button>
<a href="/path-base/integrity-policy/b">b</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
let count = $state(0);
</script>

<h1>b</h1>
<button onclick={() => (count += 1)}>b clicks: {count}</button>
58 changes: 58 additions & 0 deletions packages/kit/test/apps/options/test/test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import process from 'node:process';
import { createHash } from 'node:crypto';
import { expect } from '@playwright/test';
import { test } from '../../../utils.js';

Expand Down Expand Up @@ -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([]);
});
});
1 change: 1 addition & 0 deletions packages/kit/test/apps/options/vite.custom.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const config = {
async: true
}
},
subresourceIntegrity: 'sha384',
csp: {
directives: {
'script-src': ['self'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
// importing `prerendered` means the client bundle is rewritten after prerendering
import { prerendered } from '$app/manifest';
</script>

<pre>{JSON.stringify(prerendered)}</pre>
34 changes: 34 additions & 0 deletions packages/kit/test/prerendering/basics/test/tests.spec.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(/<link href="([^"]+)"[^>]*integrity="([^"]+)"/g),
(match) => [match[1], match[2]]
);

const import_map = html.match(/<script type="importmap"[^>]*>(.+?)<\/script>/);
if (import_map) entries.push(...Object.entries(JSON.parse(import_map[1]).integrity));

for (const [href, integrity] of entries) {
const asset = path.join(build, path.dirname(file), href);
const hash = createHash('sha384').update(fs.readFileSync(asset)).digest('base64');
expect(integrity, `${href} in ${file}`).toBe(`sha384-${hash}`);
checked += 1;
}
}

expect(checked).toBeGreaterThan(0);
});
1 change: 1 addition & 0 deletions packages/kit/test/prerendering/basics/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const config = {
plugins: [
sveltekit({
adapter: adapter(),
subresourceIntegrity: 'sha384',
paths: {
origin: 'http://prerender.origin'
},
Expand Down
Loading
Loading