Repository: sveltejs/kit (JavaScript)

Issue:
Cookie leak
### Describe the bug

It seems that cookies, acquired via fetch, in serverside requests are added to the response.

### Reproduction

Run a svelte app AND a php app on the same website. Use `fetch` in your svelte app to get something from a php website. If this fetch runs server-side the PHPSESSID from the fetch response will be added to the svelte response to the browser. Overwriting the PHPSESSID in the browser.

Since my svelte kit app does not use cookies at present, this was easily fixed with this 

```javascript
export async function handle({ event, resolve }) {
    const response = await resolve(event);
    response.headers.delete('set-cookie');
    return response;
}
```

### Logs

_No response_

### System Info

```shell
System:
    OS: Linux 5.4 Ubuntu 20.04.5 LTS (Focal Fossa)
    CPU: (128) x64 AMD EPYC 7742 64-Core Processor
    Memory: 36.57 GB / 78.07 GB
    Container: Yes
    Shell: 5.0.17 - /bin/bash
  Binaries:
    Node: 16.14.0 - ~/.asdf/installs/nodejs/16.14.0/bin/node
    npm: 8.19.0 - ~/.asdf/plugins/nodejs/shims/npm
  Browsers:
    Chrome: 105.0.5195.102
    Firefox: 104.0
```


### Severity

serious, but I can work around it

### Additional Information

_No response_


Discussion:
This sounds like the expected behavior to me? The `fetch` in server-side `load`s is meant to approximate the behavior of a browser `fetch`. So, any cookies set for this domain in the responses of any `fetch`es made in `load`s will be propagated to the final server-side-rendered response. These are the same cookies that would be set if the requests had been made from the browser.

Is the issue that the PHP server is setting a session cookie only if it doesn't find one, and the SSR `fetch`es aren't forwarding along the session cookie automatically? If so, https://kit.svelte.dev/docs/hooks#handlefetch should be able to help you manually forward this cookie.
@Conduitry yes, passing along the cookie from the browser would be an option this would also have to contain the user agent. since our particular PHP application tracks the user-agent to make sure nothing funny is happening.

This would 'solve' my problem here ... but I think there is a deeper issue.

While the path from browser to server-side fetch request requires extra work for information to flow. The path from the fetch request to the client response seems automatic.

Imagine a case where svelte app itself was storing information in a cookie, then made a server-side fetch request to some third party service. The third-party service would set a cookie with the same name as the one used in the svelte app. This new cookie would be passed on to the client which would then loose its session information vis-a-vie the svelte app.
Is the issue that when the `set-cookie` header is passed along, it's losing what domain the cookie is supposed to be set on? That _should_ be happening (meaning a third-party service would only be able to set cookies on its own subdomains). If the `set-cookie` returned by SvelteKit is setting them on a different domain than the endpoint is setting them on (or than the endpoint would be allowed to set them on), then yes that sounds like a bug, but it would need a more specific reproduction. It's hard to talk about the expected behavior when I don't know which things are subdomains of what, and which things are setting cookies where, and for that I need a more explicit reproduction.
Given the following setup ... 

If the 3rd party service is using the same name for its session cookie as the svelte-app, you run in to trouble when server-side
fetch just passes along any cookies it gets from the 3rd party app.

As long as the fetch is executed on the browser all will be well, as the two session cookies will be kept separate ... 

```
+--------------------------------------+                                            
|+------------------------------------+|                                            
||         https://svelte-app         ||                                            
|+------------------------------------+|                                            
|               Browser                |                                            
|                                      --------------------------+                  
|                                      |                         |                  
|                                      |                         |                  
|                                      |                         |                  
+-------------------|------------------+                         |                  
                    |                                            |                  
                    |                                            |                  
+-------------------|------------------+      +------------------|-----------------+
|                                      |      |                                    |
|                                      |      |                                    |
|              Svelte App              --------        3rd Party Webservice        |
|                                      |      |                                    |
|                                      |      |                                    |
+--------------------------------------+      +------------------------------------+
```
I don't 100% understand the issue - again, it really does need a repro, we don't ask for one just to make you jump through hoops - but it sounds like the problem is that `set-cookie` headers from an external domain don't include said domain in the header, and we should fill that in (and possibly `path`? need to refresh my memory on the behavior) when we include the cookie in the response to the browser
@Rich-Harris

* in your svelte page you use fetch to pull some data from a 3rd party service
* in the response from the 3rd party service there is a 'set-cookie' header

If your svelte page runs on the browser, the cookie will go into the browsers cookie db, and the next time you talk to the thrid-party, the cookie goes out with the request ... all automatic

If your svelte(kit) page is rendered on the server, again the response from the 3rd party will contain a set-cookie header ... 

Interestingly enough, the browser, when it gets the (pre rendered) svelte page from the server, will find the same 'set-cookie' header in that server response which was sent from the 3rd party service.

In the browser, this cookie is now associated with the svelte server and not with the 3rd party service.

So when the browser talks to the svelte server next, it will send the cookie along ... 

In most cases this is fine, and will even be helpful, because when svelte constructs another server-side request to the 3rd party service you can forward the cookie to the 3rd party service ...

The problem is that the forwarding of the cookies from the thrid-party service to the browser happens involuntarily and there is no name spacing ... 

While cookies from different sites are separated in the borwsers cookie db according to their domain, the cookies forwarded via svelte all end up in the same box. So if two third-party services were to use PHPSESSID (this could never happen surely) then things would become quite confusing.

Bottom line, do NOT automatically forward cookies from third party services.

guess here is where this whole 'problem' started #1198 
@oetiker
If that is really the case, it sounds like a bug.
~~But I can not imagine that this is the case, unless your 3rd-party services have the same domain that your app has.
Do you have a repro?~~

~~See~~
https://github.com/sveltejs/kit/blob/4922e26856681fdfbe3afb1a31be0d7c7000283f/packages/kit/src/runtime/server/page/fetch.js#L34-L35



> guess here is where this whole 'problem' started #1198

~~The code I mentioned was added in the PR that fixed this issue.~~
@PatrickG is url, the url the browser uses to talk to the svelte server ? if so, the problem is slightly different to what I thought it was.

In our case svelte and a headles cms with graphql support run on the same website, merged via a proxy. Whenever you access anything from the cms/graphql you get a session cookie. When logging into the cms-admin page your cookie is the ticket to greater access ... as usual.

Now, when the svelte page is rendered in the browser, all works fine, since fetch already has a cookie, nothing strange happens.

If the svelte page is rendered serverside, it also fetches, but has no cookie yet, so it gets a new one and sends this along to the browser, blindly overwriting the existing one, killing the session.

There are two ways to fix this.

a) do not send cookies you got via fetch ssr
b) pass the cookie from the browser along to the backend so that it can be set in the SSR fetch.

currently the cookie is passed automatically from the backend to the browser, but the cookie set in the browser is not passed along in the backend ... 

So either the cookies should be passed both ways (if the URL matches) or not at all ... 

Thanks for pointing out that the behaviour is limited to matching urls ... 
After looking at the code some more, I'm not really sure anymore that it works like I expected it to work.

Edit: Yes, I was wrong

https://github.com/sveltejs/kit/blob/4922e26856681fdfbe3afb1a31be0d7c7000283f/packages/kit/src/runtime/server/page/fetch.js#L34-L35

This only verifies that the cookies sent to the external service are intended for them.
The cookies from the external service are forwarded to your app.
Okay, I did a little repro.
It seems that you can't set cookies on StackBlitz, so I used replit (a bit slow, sveltekit runtime might be a bit out of date, I used a template that was created Aug 24, 2022).

The sveltekit app: https://sveltekit-cookies-3.pehgeh.repl.co/ (source https://replit.com/@PehGeh/sveltekit-cookies-3)
External service 1: https://sveltekit-cookies-1.pehgeh.repl.co/ (source https://replit.com/@PehGeh/sveltekit-cookies-1)
External service 2: https://sveltekit-cookies-2.pehgeh.repl.co/ (source https://replit.com/@PehGeh/sveltekit-cookies-2)

Both external services set a cookie `TestCookie` to `external-1` and `external-2`.
If you go to the sveltekit app, it sets a cookie `TestCookie` to `external-2`. (In fact, it sends two `set-cookie` headers)
Imagine your sveltekit app uses a cookie with the same name.

I think these cookies need to be prefixed with the domain or something.
@PatrickG since a cookie acquired while contacting a third party site in SSR can not be added to the browser's cookie store in a way that it can be used when the browser talks to the third party site directly, it makes little sense sending the cookies to the browser as cookies. How about providing some infrastructure to access the cookie jar in hook.js so that the information can be sent to the browser in a controlled manner ... 

@oetiker yeah that's true, the cookie would not be sent if we had navigated to the route via csr afterwards.
I don't know how a hook could solve this.
If the 3rd party service needs that cookie to identify you, I guess you **must** proxy it with an endpoint.
> @oetiker yeah that's true, the cookie would not be sent if we had navigated to the route via csr afterwards. I don't know how a hook could solve this. If the 3rd party service needs that cookie to identify you, I guess you **must** proxy it with an endpoint.

yes totally ... 

The bottom line remains though, sending cookies to the browser which have been acquired while talking to 3rd party services is probably a bad idea ... unless the application asks for this explicitly.
> The bottom line remains though, sending cookies to the browser which have been acquired while talking to 3rd party services is probably a bad idea ... unless the application asks for this explicitly.

Right, I wonder if the forwarding has any benefit at all if the external service is not running on the same domain (or a sub-domain).
It would make sense for `ServerLoad` (still would require a prefix of the cookie-name in my opinion), but there is no special `fetch` for them.
thank you!



Patch:
diff --git a/.changeset/witty-shoes-know.md b/.changeset/witty-shoes-know.md
new file mode 100644
index 000000000000..045cf51391d8
--- /dev/null
+++ b/.changeset/witty-shoes-know.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Only forward set-cookie headers for internal fetches
diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js
index ec728254afa0..fdd0a4658f66 100644
--- a/packages/kit/src/runtime/server/page/fetch.js
+++ b/packages/kit/src/runtime/server/page/fetch.js
@@ -172,21 +172,21 @@ export function create_fetch({ event, options, state, route, prerender_default,
 					state.prerendering.dependencies.set(url.pathname, dependency);
 				}
 
+				const set_cookie = response.headers.get('set-cookie');
+				if (set_cookie) {
+					set_cookies.push(
+						...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
+							const { name, value, ...options } = set_cookie_parser.parseString(str);
+							// options.sameSite is string, something more specific is required - type cast is safe
+							return /** @type{import('./types').Cookie} */ ({ name, value, options });
+						})
+					);
+				}
+
 				return response;
 			}
 		});
 
-		const set_cookie = response.headers.get('set-cookie');
-		if (set_cookie) {
-			set_cookies.push(
-				...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
-					const { name, value, ...options } = set_cookie_parser.parseString(str);
-					// options.sameSite is string, something more specific is required - type cast is safe
-					return /** @type{import('./types').Cookie} */ ({ name, value, options });
-				})
-			);
-		}
-
 		const proxy = new Proxy(response, {
 			get(response, key, _receiver) {
 				async function text() {


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js
new file mode 100644
index 000000000000..a968114e23a3
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js
@@ -0,0 +1,7 @@
+/** @type {import('./$types').PageLoad} */
+export async function load({ fetch, url }) {
+	const port = url.searchParams.get('port');
+	const res = await fetch(`http://localhost:${port}`);
+
+	return await res.json();
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte
new file mode 100644
index 000000000000..6e7c1e6ac83f
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte
@@ -0,0 +1,1 @@
+<h1>hello</h1>
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 0404859e5fd7..44d68b553846 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -1,5 +1,5 @@
 import { expect } from '@playwright/test';
-import { test } from '../../../utils.js';
+import { start_server, test } from '../../../utils.js';
 import { fetch } from 'undici';
 import { createHash, randomBytes } from 'node:crypto';
 
@@ -23,6 +23,29 @@ test.describe('Content-Type', () => {
 	});
 });
 
+test.describe('Cookies', () => {
+	test('does not forward cookies from external domains', async ({ request }) => {
+		const { close, port } = await start_server(async (req, res) => {
+			if (req.url === '/') {
+				res.writeHead(200, {
+					'set-cookie': 'external=true',
+					'access-control-allow-origin': '*'
+				});
+
+				res.end('ok');
+			} else {
+				res.writeHead(404);
+				res.end('not found');
+			}
+		});
+
+		const response = await request.get(`/load/fetch-external-no-cookies?port=${port}`);
+		expect(response.headers()['set-cookie']).not.toContain('external=true');
+
+		close();
+	});
+});
+
 test.describe('CSRF', () => {
 	test('Blocks requests with incorrect origin', async ({ baseURL }) => {
 		const res = await fetch(`${baseURL}/csrf`, {