Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

can no longer return multiple set-cookie headers from endpoints #3460

Closed
kamholz opened this issue Jan 20, 2022 · 32 comments
Closed

can no longer return multiple set-cookie headers from endpoints #3460

kamholz opened this issue Jan 20, 2022 · 32 comments
Labels
bug Something isn't working
Milestone

Comments

@kamholz
Copy link

kamholz commented Jan 20, 2022

Describe the bug

In a recent update, probably #3384, the ability to return multiple set-cookie headers from an endpoint seems to have broken. Instead of returning multiple set-cookie headers, it returns just one with cookie values comma-separated (which browsers cannot interpret correctly).

Reproduction

This endpoint:

export function get() {
  return {
    headers: {
      'set-cookie': [
        'accesstoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict',
        'refreshtoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict',
      ]
    }
  }
}

produces the following headers:

set-cookie: accesstoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict, refreshtoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict

but it should produce:

set-cookie: accesstoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict
set-cookie: refreshtoken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict

Logs

No response

System Info

System:
    OS: macOS 12.1
    CPU: (8) arm64 Apple M1
    Memory: 1.71 GB / 16.00 GB
    Shell: 3.2.57 - /bin/bash
  Binaries:
    Node: 17.4.0 - /opt/homebrew/bin/node
    npm: 8.3.1 - /opt/homebrew/bin/npm
  Browsers:
    Chrome: 97.0.4692.99
    Safari: 15.2
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.13 
    @sveltejs/kit: next => 1.0.0-next.235 
    svelte: ^3.44.0 => 3.46.2

Severity

blocking an upgrade

Additional Information

No response

@benmccann benmccann added the bug Something isn't working label Jan 20, 2022
@benmccann benmccann added this to the 1.0 milestone Jan 20, 2022
@kamholz
Copy link
Author

kamholz commented Jan 20, 2022

It looks like this issue goes all the way to the Headers object in the Fetch API: https://stackoverflow.com/questions/63204093/how-to-get-set-multiple-set-cookie-headers-using-fetch-api.

Is there any prospect for fixing this relatively soon, or is it going to be difficult to find a workaround? It breaks the JWT auth flow I've been using (and that I suspect various other people are), so I have to hold off updating my app until there's a fix.

@dodiameer
Copy link

I haven't tested this, but I believe you can use this as a workaround:

const headers = new Headers()
headers.append("Set-Cookie", cookie1)
headers.append("Set-Cookie", cookie2)
return {
  status: 200,
  headers
}

Headers.append() will add a value to a header instead of overwriting it according to MDN. Because you can return a Headers object in endpoints now with #3384 this should work

@kamholz
Copy link
Author

kamholz commented Jan 20, 2022

No, that doesn't work because of the issue with the Headers API. The linked Stack Overflow issue explains it.

@kamholz
Copy link
Author

kamholz commented Jan 21, 2022

There should be a way to fix this server-side by using the headers.raw() method from node-fetch(which is what provides Headers on the server). Something like this:

if (headers.has('set-cookie')) {
  const setCookieHeaders = headers.raw()['set-cookie'];
  ...
}

@isaacfranco
Copy link
Contributor

isaacfranco commented Jan 21, 2022

There should be a way to fix this server-side by using the headers.raw() method from node-fetch(which is what provides Headers on the server). Something like this:

if (headers.has('set-cookie')) {
  const setCookieHeaders = headers.raw()['set-cookie'];
  ...
}

It should be.

I tracked down the problem on the kit source code, and found that the 'set-cookit' header (when is a Array) is flattened to a single string inside the setResponse function (packages/kit/src/node.js):

res.writeHead(response.status, Object.fromEntries(response.headers));

That Object.fromEntries(response.headers) creates a single 'set-cookies', with all cookies together, separated by a comma.

What I do not understand yet, is that .raw() is not available on the response object inside that function. That would be a quick fix.

That's the first time that I opened SvelteKit source, so i'm sorry to be a little confuse....

I will try to fix that as a exercise :)

@kamholz
Copy link
Author

kamholz commented Jan 21, 2022

This seems to get the job done:

res.writeHead(response.status, getHeaders(response.headers));

function getHeaders(headers) {
  const obj = Object.fromEntries(headers);
  if (headers.has('set-cookie')) {
    obj['set-cookie'] = headers.raw()['set-cookie'];
  }
  return obj;
}

If we want to not overwrite the set-cookie key as above, which feels ugly, then it could be something like this (a lot more code though):

function getHeaders(headers) {
  if (headers.has('set-cookie')) {
    const obj = {};
    for (const [header, value] of headers.entries()) {
      obj[header] = header === 'set-cookie'
        ? headers.raw()[header]
        : value;
    }
    return obj;
  } else {
    return Object.fromEntries(headers);
  }
}

We can't simply replace Object.fromEntries(headers) with headers.raw() because we only want to return array values for Set-Cookie, not for Accept or other headers where it's possible to merge multiple values into a single string.

@isaacfranco
Copy link
Contributor

I almost did the same thing...

/** @type {import('@sveltejs/kit/node').GetHeaders} */
export function getHeaders(response) {

	let headers;
	if (response.headers.has('set-cookie')) {
		const cookieArray = response.headers.get('set-cookie')?.split(', ') || [];
		headers = Object.assign(Object.fromEntries(response.headers), {'set-cookie': cookieArray});
	} else {
		headers = Object.fromEntries(response.headers);
	}	
	return headers;
}

.....

/** @type {import('@sveltejs/kit/node').SetResponse} */
export async function setResponse(res, response) {
	
	res.writeHead(response.status, getHeaders(response));

	if (response.body instanceof Readable) {
		response.body.pipe(res);
	} else {
		if (response.body) {
			res.write(await response.arrayBuffer());
		}

		res.end();
	}
}

And... on packagets/kit/src/core/preview/index.js:

import { getRawBody, getHeaders } from '../../node.js';
.....
res.writeHead(rendered.status, getHeaders(rendered));

@isaacfranco
Copy link
Contributor

isaacfranco commented Jan 21, 2022

This seems to get the job done:

res.writeHead(response.status, getHeaders(response.headers));

function getHeaders(headers) {
  const obj = Object.fromEntries(headers);
  if (headers.has('set-cookie')) {
    obj['set-cookie'] = headers.raw()['set-cookie'];
  }
  return obj;
}

If we want to not overwrite the set-cookie key as above, which feels ugly, then it could be something like this (a lot more code though):

function getHeaders(headers) {
  if (headers.has('set-cookie')) {
    const obj = {};
    for (const [header, value] of headers.entries()) {
      obj[header] = header === 'set-cookie'
        ? headers.raw()[header]
        : value;
    }
    return obj;
  } else {
    return Object.fromEntries(headers);
  }
}

We can't simply replace Object.fromEntries(headers) with headers.raw() because we only want to return array values for Set-Cookie, not for Accept or other headers where it's possible to merge multiple values into a single string.

The problem that i had here with that approach, was that .raw() don´t exist on the response.header object....

export async function setResponse(res, response) {
	// testing raw()...
	response.headers.raw()['set-cookie']
ERROR => 
Property 'raw' does not exist on type 'Headers'.ts(2339)

Thats why I had to use split... ugly as hell.

@isaacfranco
Copy link
Contributor

isaacfranco commented Jan 21, 2022

It worked, anyway... but I feel that there is a much cleaner way to solve this bug.

@JeanJPNM
Copy link
Contributor

Using split doesn't work if one of the cookie strings contains a colon inside itself.

@isaacfranco
Copy link
Contributor

Using split doesn't work if one of the cookie strings contains a colon inside itself.

Definitely. Using split is not just ugly, it's not correct. Using raw() worked now. I confess that I don't remember what I was doing wrong.

I created a helper function on utils/http.js (don´t know if it´s the best place)

/** @param {Partial<import('@sveltejs/kit/install-fetch').Response>} response */
export function to_out_headers(response) {
	if (!response?.headers) {
		return new Headers();
	};
	
	if (response.headers.has('set-cookie')) {
		return Object.assign(Object.fromEntries(response.headers), {'set-cookie': response.headers.raw()['set-cookie']});
	} else {
		return Object.fromEntries(response.headers);
	}
}

There are two places with this bug, as far as I know, dev and preview. I'm using this helper on both and now it looks like it's gone.

Any coments on this @JeanJPNM @kamholz?

@JeanJPNM
Copy link
Contributor

Looks good to me.

@Rich-Harris
Copy link
Member

Rich-Harris commented Jan 21, 2022

Just recapping parallel discussion happening in Discord:

  • it looks like anything that uses __fetch_polyfill (which includes dev, preview, adapter-node, adapter-netlify, adapter-vercel) will need to use the .raw() trick
  • other adapters can't use that solution. but they presumably solve it in other ways. For example Cloudflare Workers implements a non-standard Headers.prototype.getAll method, so those environments should be unaffected
  • there has been discussion around standardising this behaviour, but the people responsible for those standards don't consider non-browser use cases worthwhile

@kamholz
Copy link
Author

kamholz commented Jan 21, 2022

Thanks for the update @Rich-Harris and glad that the team is thinking through this! It's weird that getAll was removed from the spec given that it would solve this in a standard way for everyone, but I guess they just weren't concerned with use cases outside the browser. node-fetch actually implements getAll, but it isn't documented -- you're supposed to use .raw().

I wonder if this is the only complication you'll run into with using the Fetch API?

@Rich-Harris
Copy link
Member

Fingers crossed. At the very least we'd be in good company, since more platforms and frameworks are moving in this direction

isaacfranco added a commit to isaacfranco/kit that referenced this issue Jan 22, 2022
…veltejs#3460

* [fix] can no longer return multiple set-cookie headers from endpoints
Rich-Harris added a commit that referenced this issue Jan 22, 2022
* [fix] can no longer return multiple set-cookie headers from endpoints #3460

* [fix] can no longer return multiple set-cookie headers from endpoints

* adding changeset

* simplify

* lint

* simplify

* tidy up and simplify test

* tweak changeset

Co-authored-by: Rich Harris <[email protected]>
@chosecz
Copy link

chosecz commented Jan 24, 2022

After that fix multiple cookies works only in dev (npm run dev) or preview mode (npm run preview), but when I run it from build with node adapter (node build/index.js), then it is again just one long string with comma separated.

"@sveltejs/kit": "^1.0.0-next.240"
"@sveltejs/adapter-node": "^1.0.0-next.66"
Node version 16.13.2

@Rich-Harris
Copy link
Member

I think that might be because I forgot to add changesets for adapter-node/netlify, meaning they didn't get rebuilt with the fix. Releasing new versions now

@Rich-Harris
Copy link
Member

closing this as it appears to be fixed

@akiarostami
Copy link

This works in endpoints, but unfortunately it still doesn't work for in the hooks.

export const handle = async ({ event, resolve }) => {
	let response = await resolve(event);
	response.headers.set('set-cookie', setCookies); // setCookies is a multi-element array
	return response;
}

Only the first item in the setCookies array gets assigned.

Should we re-open this issue?

@JeanJPNM
Copy link
Contributor

Correct me if I'm wrong, but as far as I know you are not supposed to pass an array directly into Headers.set, in your case the correct approach would be to iterate over the items of the array and append them to the set-cookie header like this:

for(const cookie of setCookies) {
  response.headers.append("set-cookie", cookie)
}

@akiarostami
Copy link

Thank you @JeanJPNM! I appreciate you pointing this out.

@kamholz earlier said "that doesn't work because of the issue with the Headers API." though. So can we use multiple appends with headers?

@fabienc974
Copy link

Hi, I have the same issue with multiple set-cookie. Before last framework update (234), everything worked well.
I've created a post on stackoverflow.
Even if I append the cookies in the headers, only the first one is added to the headers.

Here is the link on stack : Link on stack

I'm blocked and can't move on anymore on my App.

Thanks for your help.

Fabien

@isaacfranco
Copy link
Contributor

Thank you @JeanJPNM! I appreciate you pointing this out.

@kamholz earlier said "that doesn't work because of the issue with the Headers API." though. So can we use multiple appends with headers?

Yes. You could use multiple appends.

version: 1.0.0-next.252

export async function handle({ event, resolve }) {
    const response = await resolve(event);

    response.headers.append('Set-Cookie', 'handleFoo=foo');
    response.headers.append('Set-Cookie', 'handleBar=bar; HttpOnly=true');

    return response;
}

@fabienc974
Copy link

Hi,
You are adding the multiple set-cookie from within the handle function in the hooks.js file.
I want to set multiple cookies in my login.json.js file and use the handle function to set some locals.
I've tried passing my values in the body (in the login.json.js response), but when I try to access the body datas in the handle function, it says error [...] body = await request.json() [...]
Request is not available anymore.
How can I access the body datas ?
I think if I can access these datas, I can set the multiple cookies from the handle function.
Thanks for your help.

@JeanJPNM
Copy link
Contributor

JeanJPNM commented Feb 3, 2022

Hi, could you please send the error logs? They give important data about the error and make the resolution process faster.

@fabienc974
Copy link

Hi,
If I add const body =. await request.json() in the handle function, I get an infinite loop with this message
__vite_ssr_import_1__.default.json is not a function TypeError: __vite_ssr_import_1__.default.json is not a function at Object.handle (//Users/fab/Documents/MyProject/sites/svelte-frontend/src/hooks:78:29) at respond (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:2497:40) at fetch (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:1657:23) at load (__layout.svelte:3:27) at load_node (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:1778:30) at respond_with_error (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:1965:10) at async respond (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:2645:11) at async fetch (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:1657:17) at async load (__layout.svelte:3:22) at async load_node (file:///Users/fab/Documents/MyProject/sites/svelte-frontend/.svelte-kit/runtime/server/index.js:1778:12)

Hope it can help !

@fabienc974
Copy link

Hi,
Did the log help you ?
Thanks for your help.
Fabien

@JeanJPNM
Copy link
Contributor

JeanJPNM commented Feb 8, 2022

Sorry for taking so long, I cannot reproduce your issue, have you tried to reproduce the error inside another endpoint?

Noting that it is recommended to send a file with the relevant parts of the code related to the error when facing this kind of situation.

@fabienc974
Copy link

No I did not tried to access it from another end point.
I can give you access to my repo if you need !

@JeanJPNM
Copy link
Contributor

JeanJPNM commented Feb 9, 2022

Yes, I would like to see the repo.

@fabienc974
Copy link

fabienc974 commented Feb 10, 2022

Invite sent !
Please let me know if can't access it

edit:

You'll find the main svelte directory here : Lionjar / sites / svelte-frontend
And for the backend, I use strapi, Lionjar / backend-svelte-strapi

@JeanJPNM
Copy link
Contributor

I think the issue happened because you were using the spread operator ({ ...obj, foo: 1}) on the response object. Svelte kit currently uses Response objects to represent responses so using the spread operator on them may cause undesired behavior.

To solve the issue I'd recommend modifying the response headers as bellow:

	if (user || loggingOut) {
		response.headers.append("Set-Cookie", setCookieValue)
		response.headers.append("Set-Cookie", setJwtCookieValue)
		response.headers.append("Set-Cookie", setCompIdCookieValue)
	}
	return response

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

9 participants