Repository: sveltejs/kit (js)

Issue:
fetch passed to load function in +page.server.js implements CORS
### Describe the bug

Even though it will only ever run on the server, the fetch implementation that is passed to load in +page.server.js is checking for CORS, behaving as if it were called in a browser.

I assume this might be an oversight from when +page.server routes were first introduced, since obviously you'd very much want this behavior in non-server page.js load function, but it doesn't really make any sense in the new environment.

I hope I didn't overlook an issue where this was discussed already and kept for technical reasons, but even then we'd probably want this documented.

### Reproduction

https://stackblitz.com/edit/sveltejs-kit-template-default-52u2qj?file=src%2Froutes%2F%2Blayout.svelte,src%2Froutes%2F%2Bpage.svelte,src%2Froutes%2F%2Bpage.server.js&terminal=dev

### Logs

_No response_

### System Info

```Shell
stackblitz
```


### Severity

annoyance

The global fetch correctly doesn't implement CORS, so one can simply use that. However it is a bit confusing getting a CORS error from a request you make on the server.

### Additional Information

_No response_

Pull request:
allow cross-origin requests from server-only `load` functions
fixes #7441

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/tidy-tables-think.md b/.changeset/tidy-tables-think.md
new file mode 100644
index 000000000..a79fb94e8
--- /dev/null
+++ b/.changeset/tidy-tables-think.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] fetch erroring on Cloudflare
diff --git a/.changeset/wet-news-tie.md b/.changeset/wet-news-tie.md
new file mode 100644
index 000000000..1d072a376
--- /dev/null
+++ b/.changeset/wet-news-tie.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Only simulate CORS errors for shared load functions
diff --git a/packages/adapter-static/README.md b/packages/adapter-static/README.md
index 540239ae8..8da5c9dbf 100644
--- a/packages/adapter-static/README.md
+++ b/packages/adapter-static/README.md
@@ -138,6 +138,21 @@ You can also add turn off prerendering only to parts of your app, if you want ot
 
 During development, SvelteKit will still attempt to server-side render your routes. This means accessing things that are only available in the browser (such as the `window` object) will result in errors, even though this would be valid in the output app. To align the behavior of SvelteKit's dev mode with your SPA, you can [add `export const ssr = false` to your root `+layout`](https://kit.svelte.dev/docs/page-options#ssr). You can also add this option only to parts of your app, if you want other parts to be prerendered.
 
+### Apache
+
+To run an SPA on [Apache](https://httpd.apache.org/), you should add a `static/.htaccess` file to route requests to the fallback page:
+
+```
+<IfModule mod_rewrite.c>
+  RewriteEngine On
+  RewriteBase /
+  RewriteRule ^200\.html$ - [L]
+  RewriteCond %{REQUEST_FILENAME} !-f
+  RewriteCond %{REQUEST_FILENAME} !-d
+  RewriteRule . /200.html [L]
+</IfModule>
+```
+
 ## GitHub Pages
 
 When building for GitHub Pages, make sure to update [`paths.base`](https://kit.svelte.dev/docs/configuration#paths) to match your repo name, since the site will be served from <https://your-username.github.io/your-repo-name> rather than from the root.
diff --git a/packages/kit/src/runtime/server/fetch.js b/packages/kit/src/runtime/server/fetch.js
index 1632db5b8..1b1b60bf6 100644
--- a/packages/kit/src/runtime/server/fetch.js
+++ b/packages/kit/src/runtime/server/fetch.js
@@ -12,13 +12,19 @@ import { respond } from './index.js';
  */
 export function create_fetch({ event, options, state, get_cookie_header }) {
 	return async (info, init) => {
-		const request = normalize_fetch_input(info, init, event.url);
+		const original_request = normalize_fetch_input(info, init, event.url);
 
 		const request_body = init?.body;
 
+		// some runtimes (e.g. Cloudflare) error if you access `request.mode`,
+		// annoyingly, so we need to read the value from the `init` object instead
+		let mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors';
+		let credentials =
+			(info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin';
+
 		return await options.hooks.handleFetch({
 			event,
-			request,
+			request: original_request,
 			fetch: async (info, init) => {
 				const request = normalize_fetch_input(info, init, event.url);
 
@@ -28,10 +34,16 @@ export function create_fetch({ event, options, state, get_cookie_header }) {
 					request.headers.set('origin', event.url.origin);
 				}
 
+				if (info !== original_request) {
+					mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors';
+					credentials =
+						(info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin';
+				}
+
 				// Remove Origin, according to https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin#description
 				if (
 					(request.method === 'GET' || request.method === 'HEAD') &&
-					((request.mode === 'no-cors' && url.origin !== event.url.origin) ||
+					((mode === 'no-cors' && url.origin !== event.url.origin) ||
 						url.origin === event.url.origin)
 				) {
 					request.headers.delete('origin');
@@ -46,33 +58,19 @@ export function create_fetch({ event, options, state, get_cookie_header }) {
 					// - sub.my.domain.com WILL receive cookies
 					// ports do not affect the resolution
 					// leading dot prevents mydomain.com matching domain.com
-					if (
-						`.${url.hostname}`.endsWith(`.${event.url.hostname}`) &&
-						request.credentials !== 'omit'
-					) {
+					if (`.${url.hostname}`.endsWith(`.${event.url.hostname}`) && credentials !== 'omit') {
 						const cookie = get_cookie_header(url, request.headers.get('cookie'));
 						if (cookie) request.headers.set('cookie', cookie);
 					}
 
 					let response = await fetch(request);
 
-					if (request.mode === 'no-cors') {
+					if (mode === 'no-cors') {
 						response = new Response('', {
 							status: response.status,
 							statusText: response.statusText,
 							headers: response.headers
 						});
-					} else {
-						if (url.origin !== event.url.origin) {
-							const acao = response.headers.get('access-control-allow-origin');
-							if (!acao || (acao !== event.url.origin && acao !== '*')) {
-								throw new Error(
-									`CORS error: ${
-										acao ? 'Incorrect' : 'No'
-									} 'Access-Control-Allow-Origin' header is present on the requested resource`
-								);
-							}
-						}
 					}
 
 					return response;
@@ -109,7 +107,7 @@ export function create_fetch({ event, options, state, get_cookie_header }) {
 					return await fetch(request);
 				}
 
-				if (request.credentials !== 'omit') {
+				if (credentials !== 'omit') {
 					const cookie = get_cookie_header(url, request.headers.get('cookie'));
 					if (cookie) {
 						request.headers.set('cookie', cookie);
diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js
index 73d25dbd0..dec13492c 100644
--- a/packages/kit/src/runtime/server/page/load_data.js
+++ b/packages/kit/src/runtime/server/page/load_data.js
@@ -109,9 +109,24 @@ export async function load_data({
 			/** @type {import('types').PrerenderDependency} */
 			let dependency;
 
-			if (same_origin && state.prerendering) {
-				dependency = { response, body: null };
-				state.prerendering.dependencies.set(url.pathname, dependency);
+			if (same_origin) {
+				if (state.prerendering) {
+					dependency = { response, body: null };
+					state.prerendering.dependencies.set(url.pathname, dependency);
+				}
+			} else {
+				// simulate CORS errors server-side for consistency with client-side behaviour
+				const mode = input instanceof Request ? input.mode : init?.mode ?? 'cors';
+				if (mode !== 'no-cors') {
+					const acao = response.headers.get('access-control-allow-origin');
+					if (!acao || (acao !== event.url.origin && acao !== '*')) {
+						throw new Error(
+							`CORS error: ${
+								acao ? 'Incorrect' : 'No'
+							} 'Access-Control-Allow-Origin' header is present on the requested resource`
+						);
+					}
+				}
 			}
 
 			const proxy = new Proxy(response, {
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index b24a9c14f..b6ca8aa2d 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -246,7 +246,7 @@ export async function render_response({
 				env: ${s(options.public_env)},
 				hydrate: ${page_config.ssr ? `{
 					status: ${status},
-					error: ${s(error)},
+					error: ${devalue.uneval(error)},
 					node_ids: [${branch.map(({ node }) => node.index).join(', ')}],
 					params: ${devalue.uneval(event.params)},
 					routeId: ${s(event.routeId)},


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js
new file mode 100644
index 000000000..9fbd10aed
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js
@@ -0,0 +1,8 @@
+/** @type {import('./$types').PageLoad} */
+export async function load({ fetch, url }) {
+	const res = await fetch(`http://localhost:${url.searchParams.get('port')}`);
+
+	return {
+		text: await res.text()
+	};
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte
new file mode 100644
index 000000000..a5063216b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte
@@ -0,0 +1,6 @@
+<script>
+	/** @type {import('./$types').PageData} */
+	export let data;
+</script>
+
+<h1>{@html data.text}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/xss/[path]/+page.svelte b/packages/kit/test/apps/basics/src/routes/xss/[path]/+page.svelte
index d4f913913..e8dcc4cff 100644
--- a/packages/kit/test/apps/basics/src/routes/xss/[path]/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/xss/[path]/+page.svelte
@@ -2,4 +2,4 @@
 	import { page } from '$app/stores';
 </script>
 
-<h1 id="one">{$page.params.slug}</h1>
+<h1 id="one">{$page.params.path}</h1>
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index b352c5b40..485d1f0a9 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -936,7 +936,10 @@ test.describe('Load', () => {
 		expect(await page.textContent('h1')).toBe('true');
 	});
 
-	test('CORS errors are simulated server-side', async ({ page, read_errors }) => {
+	test('CORS errors are simulated server-side for shared load functions', async ({
+		page,
+		read_errors
+	}) => {
 		const { port, close } = await start_server(async (req, res) => {
 			res.end('hello');
 		});
@@ -952,6 +955,17 @@ test.describe('Load', () => {
 
 		await close();
 	});
+
+	test('CORS errors are skipped for server-only load functions', async ({ page }) => {
+		const { port, close } = await start_server(async (req, res) => {
+			res.end('hello');
+		});
+
+		await page.goto(`/load/cors/server-only?port=${port}`);
+		expect(await page.textContent('h1')).toBe('hello');
+
+		await close();
+	});
 });
 
 test.describe('Nested layouts', () => {
@@ -1729,16 +1743,23 @@ test.describe('XSS', () => {
 		}
 	});
 
-	const uri_xss_payload = encodeURIComponent('</script><script>window.pwned=1</script>');
+	const uri_xss_payload = '</script><script>window.pwned=1</script>';
+	const uri_xss_payload_encoded = encodeURIComponent(uri_xss_payload);
+
 	test('no xss via dynamic route path', async ({ page }) => {
-		await page.goto(`/xss/${uri_xss_payload}`);
+		await page.goto(`/xss/${uri_xss_payload_encoded}`);
+
+		expect(await page.textContent('h1')).toBe(uri_xss_payload);
 
 		// @ts-expect-error - check global injected variable
 		expect(await page.evaluate(() => window.pwned)).toBeUndefined();
 	});
 
 	test('no xss via query param', async ({ page }) => {
-		await page.goto(`/xss/query?key=${uri_xss_payload}`);
+		await page.goto(`/xss/query?key=${uri_xss_payload_encoded}`);
+
+		expect(await page.textContent('#one')).toBe(JSON.stringify({ key: [uri_xss_payload] }));
+		expect(await page.textContent('#two')).toBe(JSON.stringify({ key: [uri_xss_payload] }));
 
 		// @ts-expect-error - check global injected variable
 		expect(await page.evaluate(() => window.pwned)).toBeUndefined();
diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js
index ed1c58d0b..a60ec8e56 100644
--- a/packages/kit/test/utils.js
+++ b/packages/kit/test/utils.js
@@ -141,7 +141,7 @@ export const config = {
 		command: process.env.DEV ? 'pnpm dev' : 'pnpm build && pnpm preview',
 		port: process.env.DEV ? 5173 : 4173
 	},
-	retries: process.env.CI ? 5 : 0,
+	retries: process.env.CI ? 2 : 0,
 	projects: [
 		{
 			name: `${test_browser}-${process.env.DEV ? 'dev' : 'build'}`,