Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 6 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,11 @@ const get_defaults = (prefix = '') => ({
},
inlineStyleThreshold: 0,
moduleExtensions: ['.js', '.ts'],
output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' },
integrityPolicy: { endpoints: ['default'] },
output: {
preloadStrategy: 'modulepreload',
bundleStrategy: 'split'
},
outDir: join(prefix, '.svelte-kit'),
router: {
type: 'pathname',
Expand All @@ -109,6 +113,7 @@ const get_defaults = (prefix = '') => ({
serviceWorker: {
register: true
},
subresourceIntegrity: false,
typescript: {},
paths: {
base: '',
Expand Down
12 changes: 12 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ const options = object(

inlineStyleThreshold: number(0),

integrityPolicy: object({
endpoints: string_array(['default'])
}),

moduleExtensions: string_array(['.js', '.ts']),

outDir: string('.svelte-kit'),
Expand Down Expand Up @@ -304,6 +308,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
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const options = {
hash_routing: ${s(config.kit.router.type === 'hash')},
hooks: null, // added lazily, via \`get_hooks\`
preload_strategy: ${s(config.kit.output.preloadStrategy)},
integrity_policy_endpoints: ${s(config.kit.integrityPolicy.endpoints)},
root,
service_worker: ${has_service_worker},
service_worker_options: ${config.kit.serviceWorker.register ? s(config.kit.serviceWorker.options) : 'null'},
Expand Down
15 changes: 15 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,16 @@ export interface KitConfig {
* @default 0
*/
inlineStyleThreshold?: number;
/**
* Configuration for the [`Integrity-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Integrity-Policy) response header, which is set when `subresourceIntegrity` is enabled.
*/
integrityPolicy?: {
/**
* The reporting endpoints to include in the `Integrity-Policy` header.
* @default ["default"]
*/
endpoints?: string[];
};
/**
* An array of file extensions that SvelteKit will treat as modules. Files with extensions that match neither `config.extensions` nor `config.kit.moduleExtensions` will be ignored by the router.
* @default [".js", ".ts"]
Expand Down Expand Up @@ -846,6 +856,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
18 changes: 18 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,23 @@ async function kit({ svelte_config }) {
}
}

// Compute SRI integrity hashes
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 = chunk.type === 'chunk' ? chunk.code : chunk.source;
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;
49 changes: 38 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 @@ -598,6 +615,15 @@ export async function render_response({
if (link_headers.size) {
headers.set('link', Array.from(link_headers).join(', '));
}

if (integrity_map) {
const destinations = 'script style';
const endpoints = options.integrity_policy_endpoints.join(' ');
headers.set(
'integrity-policy',
`blocked-destinations=(${destinations}),endpoints=(${endpoints})`
);
}
}

const html = options.templates.app({
Expand Down Expand Up @@ -712,11 +738,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
3 changes: 3 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 Expand Up @@ -478,6 +480,7 @@ export interface SSROptions {
}): string;
error(values: { message: string; status: number }): string;
};
integrity_policy_endpoints: string[];
version_hash: string;
}

Expand Down
22 changes: 22 additions & 0 deletions packages/kit/test/apps/options-4/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "test-options-4",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync",
"check": "svelte-kit sync && tsc && svelte-check",
"test": "playwright test"
},
"devDependencies": {
"@sveltejs/kit": "workspace:^",
"@sveltejs/vite-plugin-svelte": "catalog:",
"svelte": "catalog:",
"svelte-check": "catalog:",
"typescript": "^5.5.4",
"vite": "catalog:"
},
"type": "module"
}
1 change: 1 addition & 0 deletions packages/kit/test/apps/options-4/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { config as default } from '../../utils.js';
11 changes: 11 additions & 0 deletions packages/kit/test/apps/options-4/src/app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
7 changes: 7 additions & 0 deletions packages/kit/test/apps/options-4/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import { setup } from '../../../../setup.js';

setup();
</script>

<slot />
1 change: 1 addition & 0 deletions packages/kit/test/apps/options-4/src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>SRI test</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1 class="styled">Styled page</h1>

<style>
.styled {
color: red;
}
</style>
11 changes: 11 additions & 0 deletions packages/kit/test/apps/options-4/svelte.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
subresourceIntegrity: 'sha384',
integrityPolicy: {
endpoints: ['default']
}
}
};

export default config;
Loading
Loading