From 57e4b85834b2cc47d6adef85a60b1704e89c28e5 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 15:47:24 -0700 Subject: [PATCH 1/5] feat: whitelist external remote functions --- packages/kit/src/core/config/index.spec.js | 3 +++ packages/kit/src/core/config/options.js | 4 ++++ .../core/sync/create_manifest_data/index.js | 5 +++-- packages/kit/src/exports/public.d.ts | 9 ++++++++ packages/kit/src/exports/vite/dev/index.js | 4 +++- packages/kit/src/exports/vite/index.js | 22 +++++++++++++++++-- packages/kit/src/runtime/server/cookie.js | 9 +------- packages/kit/src/utils/array.js | 9 ++++++++ .../allowed/allowed.remote.js | 3 +++ .../not-allowed/not-allowed.remote.js | 3 +++ .../basics/src/routes/remote/+page.svelte | 21 ++++++++++++++++++ .../kit/test/apps/basics/svelte.config.js | 3 +++ .../kit/test/apps/basics/test/client.test.js | 9 ++++++++ packages/kit/types/index.d.ts | 9 ++++++++ 14 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js create mode 100644 packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 90da427483d7..ed0c5c47732a 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -97,6 +97,9 @@ const get_defaults = (prefix = '') => ({ moduleExtensions: ['.js', '.ts'], output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, outDir: join(prefix, '.svelte-kit'), + remoteFunctions: { + allowedPaths: [] + }, router: { type: 'pathname', resolution: 'client' diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 577ca4c9445d..948032370c41 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -264,6 +264,10 @@ const options = object( }) }), + remoteFunctions: object({ + allowedPaths: string_array([]) + }), + router: object({ type: list(['pathname', 'hash']), resolution: list(['client', 'server']) diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index a121ac189be0..9f40e7881015 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -480,8 +480,9 @@ function create_remotes(config, cwd) { /** @type {import('types').ManifestData['remotes']} */ const remotes = []; - // TODO could files live in other directories, including node_modules? - for (const dir of [config.kit.files.lib, config.kit.files.routes]) { + const externals = config.kit.remoteFunctions.allowedPaths.map((dir) => path.resolve(dir)); + + for (const dir of [config.kit.files.lib, config.kit.files.routes, ...externals]) { if (!fs.existsSync(dir)) continue; for (const file of walk(dir)) { diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 1982000eb2f9..c4d6e4256d1d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -657,6 +657,15 @@ export interface KitConfig { */ origin?: string; }; + remoteFunctions?: { + /** + * A list of external paths that are allowed to provide remote functions. + * By default, remote functions are only allowed inside the `routes` and `lib` folders. + * + * Accepts absolute paths or paths relative to the project root. + */ + allowedPaths?: string[]; + }; router?: { /** * What type of client-side router to use. diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index a5f211efa9af..2a6a4d1370f4 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -29,9 +29,10 @@ const vite_css_query_regex = /(?:\?|&)(?:raw|url|inline)(?:&|$)/; * @param {import('vite').ViteDevServer} vite * @param {import('vite').ResolvedConfig} vite_config * @param {import('types').ValidatedConfig} svelte_config + * @param {(manifest_data: import('types').ManifestData) => void} set_manifest_data * @return {Promise void>>} */ -export async function dev(vite, vite_config, svelte_config) { +export async function dev(vite, vite_config, svelte_config, set_manifest_data) { installPolyfills(); const async_local_storage = new AsyncLocalStorage(); @@ -108,6 +109,7 @@ export async function dev(vite, vite_config, svelte_config) { function update_manifest() { try { ({ manifest_data } = sync.create(svelte_config)); + set_manifest_data(manifest_data); if (manifest_error) { manifest_error = null; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 31a79718fcb1..95d55afb9a42 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -35,7 +35,7 @@ import { sveltekit_server } from './module_ids.js'; import { import_peer } from '../../utils/import.js'; -import { compact } from '../../utils/array.js'; +import { compact, conjoin } from '../../utils/array.js'; import { build_remotes, treeshake_prerendered_remotes } from './build/build_remote.js'; const cwd = process.cwd(); @@ -668,6 +668,24 @@ Tips: } } + if (!manifest_data.remotes.some((remote) => remote.hash === hashed)) { + const relative_path = path.relative(dev_server.config.root, id); + const fn_names = [...remotes.values()].flat().map((name) => `"${name}"`); + const has_multiple = fn_names.length !== 1; + console.warn( + colors + .bold() + .yellow( + `Remote function${has_multiple ? 's' : ''} ${conjoin(fn_names)} from ${relative_path} ${has_multiple ? 'are' : 'is'} not accessible by default.` + ) + ); + console.warn( + colors.yellow( + `To whitelist ${has_multiple ? 'them' : 'it'}, add "${path.dirname(relative_path)}" to \`kit.remoteFunctions.allowedPaths\` in \`svelte.config.js\`.` + ) + ); + } + let namespace = '__remote'; let uid = 1; while (remotes.has(namespace)) namespace = `__remote${uid++}`; @@ -842,7 +860,7 @@ Tips: * @see https://vitejs.dev/guide/api-plugin.html#configureserver */ async configureServer(vite) { - return await dev(vite, vite_config, svelte_config); + return await dev(vite, vite_config, svelte_config, (data) => (manifest_data = data)); }, /** diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js index 2e683543a534..9019c86f0517 100644 --- a/packages/kit/src/runtime/server/cookie.js +++ b/packages/kit/src/runtime/server/cookie.js @@ -1,4 +1,5 @@ import { parse, serialize } from 'cookie'; +import { conjoin } from '../../utils/array.js'; import { normalize_path, resolve } from '../../utils/url.js'; import { add_data_suffix } from '../pathname.js'; @@ -286,11 +287,3 @@ export function add_cookies_to_headers(headers, cookies) { } } } - -/** - * @param {string[]} array - */ -function conjoin(array) { - if (array.length <= 2) return array.join(' and '); - return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`; -} diff --git a/packages/kit/src/utils/array.js b/packages/kit/src/utils/array.js index 08f93845149b..1d6dc4b6abce 100644 --- a/packages/kit/src/utils/array.js +++ b/packages/kit/src/utils/array.js @@ -7,3 +7,12 @@ export function compact(arr) { return arr.filter(/** @returns {val is NonNullable} */ (val) => val != null); } + +/** + * Joins an array of strings with commas and 'and'. + * @param {string[]} array + */ +export function conjoin(array) { + if (array.length <= 2) return array.join(' and '); + return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`; +} diff --git a/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js b/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js new file mode 100644 index 000000000000..53c1d3dbce30 --- /dev/null +++ b/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js @@ -0,0 +1,3 @@ +import { query } from '$app/server'; + +export const external_allowed = query(async () => 'external success'); diff --git a/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js b/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js new file mode 100644 index 000000000000..bc60d4190a56 --- /dev/null +++ b/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js @@ -0,0 +1,3 @@ +import { query } from '$app/server'; + +export const external_not_allowed = query(async () => 'external failure'); diff --git a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte index a05f83efb528..2544c97f80fe 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte @@ -2,6 +2,8 @@ import { browser } from '$app/environment'; import { refreshAll } from '$app/navigation'; import { add, get_count, set_count, set_count_server } from './query-command.remote.js'; + import { external_allowed } from '../../external-remotes/allowed/allowed.remote.js'; + import { external_not_allowed } from '../../external-remotes/not-allowed/not-allowed.remote.js'; let { data } = $props(); @@ -9,6 +11,9 @@ let release; const count = browser ? get_count() : null; // so that we get a remote request in the browser + + const external_allowed_result = browser ? external_allowed() : null; + const external_not_allowed_result = browser ? external_not_allowed() : null;

{data.echo_result}

@@ -65,3 +70,19 @@ + +

+ allowed: {#await external_allowed_result then result} + {result} + {:catch error} + {error} + {/await} +

+ +

+ not allowed: {#await external_not_allowed_result then result} + {result} + {:catch error} + {error} + {/await} +

diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 2410ff83d57f..2030051947c0 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -43,6 +43,9 @@ const config = { }, router: { resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' + }, + remoteFunctions: { + allowedPaths: ['src/external-remotes/allowed'] } } }; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 1c4db540aef4..a54aba30ab60 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1834,4 +1834,13 @@ test.describe('remote functions', () => { await page.click('button:nth-of-type(4)'); await expect(page.locator('p')).toHaveText('success'); }); + + test('external remote whitelisting works', async ({ page }) => { + await page.goto('/remote'); + await expect(page.locator('#external-allowed')).toHaveText('external success'); + await expect(page.locator('#external-not-allowed')).not.toHaveText('external failure'); + await expect(page.locator('#external-not-allowed')).toHaveText( + 'Failed to execute remote function' + ); + }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 5458c54e9715..c78d4b631dcc 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -634,6 +634,15 @@ declare module '@sveltejs/kit' { */ origin?: string; }; + remoteFunctions?: { + /** + * A list of external paths that are allowed to provide remote functions. + * By default, remote functions are only allowed inside the `routes` and `lib` folders. + * + * Accepts absolute paths or paths relative to the project root. + */ + allowedPaths?: string[]; + }; router?: { /** * What type of client-side router to use. From 649089f96772d8ba2b6e1c61f50253b6d6f706e8 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 15:58:06 -0700 Subject: [PATCH 2/5] changeset --- .changeset/whole-symbols-cover.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/whole-symbols-cover.md diff --git a/.changeset/whole-symbols-cover.md b/.changeset/whole-symbols-cover.md new file mode 100644 index 000000000000..9ec2e3f36e99 --- /dev/null +++ b/.changeset/whole-symbols-cover.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow external remote functions (such as from `node_modules`) to be whitelisted From 9ffc9cfd82f0d2d934be74139e89e66f3331ae9f Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 16:20:33 -0700 Subject: [PATCH 3/5] fix tests --- .../src/external-remotes/allowed.remote.js | 3 +++ .../external-remotes/allowed/allowed.remote.js | 3 --- .../not-allowed/not-allowed.remote.js | 3 --- .../apps/basics/src/routes/remote/+page.svelte | 16 +++------------- packages/kit/test/apps/basics/svelte.config.js | 2 +- .../kit/test/apps/basics/test/client.test.js | 12 ++++-------- 6 files changed, 11 insertions(+), 28 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js delete mode 100644 packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js delete mode 100644 packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js diff --git a/packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js b/packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js new file mode 100644 index 000000000000..ea5b438b138c --- /dev/null +++ b/packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js @@ -0,0 +1,3 @@ +import { query } from '$app/server'; + +export const external = query(async () => 'external success'); diff --git a/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js b/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js deleted file mode 100644 index 53c1d3dbce30..000000000000 --- a/packages/kit/test/apps/basics/src/external-remotes/allowed/allowed.remote.js +++ /dev/null @@ -1,3 +0,0 @@ -import { query } from '$app/server'; - -export const external_allowed = query(async () => 'external success'); diff --git a/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js b/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js deleted file mode 100644 index bc60d4190a56..000000000000 --- a/packages/kit/test/apps/basics/src/external-remotes/not-allowed/not-allowed.remote.js +++ /dev/null @@ -1,3 +0,0 @@ -import { query } from '$app/server'; - -export const external_not_allowed = query(async () => 'external failure'); diff --git a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte index 2544c97f80fe..a689593e83a7 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte @@ -2,8 +2,7 @@ import { browser } from '$app/environment'; import { refreshAll } from '$app/navigation'; import { add, get_count, set_count, set_count_server } from './query-command.remote.js'; - import { external_allowed } from '../../external-remotes/allowed/allowed.remote.js'; - import { external_not_allowed } from '../../external-remotes/not-allowed/not-allowed.remote.js'; + import { external } from '../../external-remotes/allowed.remote.js'; let { data } = $props(); @@ -12,8 +11,7 @@ const count = browser ? get_count() : null; // so that we get a remote request in the browser - const external_allowed_result = browser ? external_allowed() : null; - const external_not_allowed_result = browser ? external_not_allowed() : null; + const external_result = browser ? external() : null;

{data.echo_result}

@@ -72,15 +70,7 @@

- allowed: {#await external_allowed_result then result} - {result} - {:catch error} - {error} - {/await} -

- -

- not allowed: {#await external_not_allowed_result then result} + {#await external_result then result} {result} {:catch error} {error} diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 2030051947c0..74b0d6fab3a2 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -45,7 +45,7 @@ const config = { resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' }, remoteFunctions: { - allowedPaths: ['src/external-remotes/allowed'] + allowedPaths: ['src/external-remotes'] } } }; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index a54aba30ab60..a4454980c9db 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1661,7 +1661,7 @@ test.describe('remote functions', () => { await page.goto('/remote'); await expect(page.locator('#count-result')).toHaveText('0 / 0 (false)'); // only the calls in the template are done, not the one in the load function - expect(request_count).toBe(2); + expect(request_count).toBe(3); }); test('command returns correct sum and refreshes all data by default', async ({ page }) => { @@ -1675,7 +1675,7 @@ test.describe('remote functions', () => { await expect(page.locator('#command-result')).toHaveText('2'); await expect(page.locator('#count-result')).toHaveText('2 / 2 (false)'); await page.waitForTimeout(100); // allow all requests to finish - expect(request_count).toBe(4); // 1 for the command, 3 for the refresh + expect(request_count).toBe(5); // 1 for the command, 4 for the refresh }); test('command returns correct sum and does client-initiated single flight mutation', async ({ @@ -1798,7 +1798,7 @@ test.describe('remote functions', () => { await page.click('#refresh-all'); await page.waitForTimeout(100); // allow things to rerun - expect(request_count).toBe(3); + expect(request_count).toBe(4); }); test('refreshAll({ includeLoadFunctions: false }) reloads remote functions only', async ({ @@ -1812,7 +1812,7 @@ test.describe('remote functions', () => { await page.click('#refresh-remote-only'); await page.waitForTimeout(100); // allow things to rerun - expect(request_count).toBe(2); + expect(request_count).toBe(3); }); test('validation works', async ({ page }) => { @@ -1838,9 +1838,5 @@ test.describe('remote functions', () => { test('external remote whitelisting works', async ({ page }) => { await page.goto('/remote'); await expect(page.locator('#external-allowed')).toHaveText('external success'); - await expect(page.locator('#external-not-allowed')).not.toHaveText('external failure'); - await expect(page.locator('#external-not-allowed')).toHaveText( - 'Failed to execute remote function' - ); }); }); From c0f9b0a22d52b68fa959d4d213fdb3d2fdcefe00 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Wed, 13 Aug 2025 10:47:00 -0700 Subject: [PATCH 4/5] merge --- packages/kit/src/core/sync/create_manifest_data/index.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index 1a0ce6e63b9d..e13bdbec1699 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -489,10 +489,11 @@ function create_remotes(config, cwd) { if (extensions.some((ext) => file.endsWith(ext))) { const posixified = posixify(path.relative(cwd, `${dir}/${file}`)); - remotes.push({ - hash: hash(posixified), - file: posixified - }); + remotes.push({ + hash: hash(posixified), + file: posixified + }); + } } } From d4be49c527237d5103a070dc6bdcd986251e3c6c Mon Sep 17 00:00:00 2001 From: Ottomated Date: Wed, 13 Aug 2025 10:49:49 -0700 Subject: [PATCH 5/5] move test out of src folder --- .../apps/basics/{src => }/external-remotes/allowed.remote.js | 0 packages/kit/test/apps/basics/src/routes/remote/+page.svelte | 2 +- packages/kit/test/apps/basics/svelte.config.js | 2 +- packages/kit/test/apps/basics/vite.config.js | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/kit/test/apps/basics/{src => }/external-remotes/allowed.remote.js (100%) diff --git a/packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js b/packages/kit/test/apps/basics/external-remotes/allowed.remote.js similarity index 100% rename from packages/kit/test/apps/basics/src/external-remotes/allowed.remote.js rename to packages/kit/test/apps/basics/external-remotes/allowed.remote.js diff --git a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte index 9db94495329a..28daca5271e6 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte @@ -8,7 +8,7 @@ set_count_server, resolve_deferreds } from './query-command.remote.js'; - import { external } from '../../external-remotes/allowed.remote.js'; + import { external } from '../../../external-remotes/allowed.remote.js'; let { data } = $props(); diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 74b0d6fab3a2..7dc7981588cf 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -45,7 +45,7 @@ const config = { resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' }, remoteFunctions: { - allowedPaths: ['src/external-remotes'] + allowedPaths: ['./external-remotes'] } } }; diff --git a/packages/kit/test/apps/basics/vite.config.js b/packages/kit/test/apps/basics/vite.config.js index d6742f261346..929c26dae4a5 100644 --- a/packages/kit/test/apps/basics/vite.config.js +++ b/packages/kit/test/apps/basics/vite.config.js @@ -15,7 +15,7 @@ const config = { plugins: [sveltekit()], server: { fs: { - allow: [path.resolve('../../../src')] + allow: [path.resolve('../../../src'), path.resolve('external-remotes')] } } };