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

Edge Functions: maxAge breaks Set-Cookie header #30430

Closed
dferber90 opened this issue Oct 27, 2021 · 6 comments · Fixed by #30560
Closed

Edge Functions: maxAge breaks Set-Cookie header #30430

dferber90 opened this issue Oct 27, 2021 · 6 comments · Fixed by #30560
Labels
bug Issue was opened via the bug report template.

Comments

@dferber90
Copy link
Contributor

dferber90 commented Oct 27, 2021

What version of Next.js are you using?

12.0.0

What version of Node.js are you using?

16.3.0

What browser are you using?

Chrome, Firefox

What operating system are you using?

macOS

How are you deploying your application?

Vercel

Describe the Bug

When using _middleware.ts and setting a cookie via res.cookie(COOKIE_NAME, bucket, { maxAge: 10000 }), the response contains two broken Set-Cookie headers instead of one correct one.

Here is the response to the request in Chrome:

image

Notice how the first set-cookie header abruptly stops after "Wed" and the content that would follow there then follows in a separate Set-Cookie header, except that the comma that should be after "Wed" is missing.

Response headers as text
set-cookie: bucket-marketing=original; Max-Age=10; Path=/; Expires=Wed
set-cookie: 27 Oct 2021 11:43:14 GMT

The value of Set-Cookie is originally something like bucket-marketing=original; Max-Age=10; Path=/; Expires=Wed, 27 Oct 2021 11:43:14 GMT and it seems as if the , splits the header into two.

I tested, and this does not happen with other headers with the same value.

This seems to be a regression. The same code is working in ^11.1.3-canary.104 but not in 12.0.0.

Expected Behavior

Only one Set-Cookie header is added to the response.

To Reproduce

Clone the Simple AB Testing example and adapt:

git clone git@github.com:vercel/examples.git

Update the next.js version to v12 by editing examples/edge-functions/ab-testing-simple/package.json:

  "dependencies": {
-   "next": "^11.1.3-canary.104"
+   "next": "12.0.0",
  },

Run yarn in examples/edge-functions/ab-testing-simple.

Then edit examples/edge-functions/ab-testing-simple/pages/marketing/_middleware to:

import { NextRequest, NextResponse } from 'next/server'
import { getBucket } from '@lib/ab-testing'
import { MARKETING_BUCKETS } from '@lib/buckets'

const COOKIE_NAME = 'bucket-marketing'

export function middleware(req: NextRequest) {
  const bucket = req.cookies[COOKIE_NAME] || getBucket(MARKETING_BUCKETS)
  const res = NextResponse.rewrite(`/marketing/${bucket}`)

  // Removed the if-block to always set the cookie

  // Adding the maxAge option (this breaks the cookie)
  res.cookie(COOKIE_NAME, bucket, { maxAge: 10000 })

  return res
}

Run yarn dev to start Next.js.

Open http://localhost:3000/marketing and inspect the response to the marketing site. You will see two Set-Cookie headers. This only happens when maxAge or expires are set in res.cookie().

@dferber90 dferber90 added the bug Issue was opened via the bug report template. label Oct 27, 2021
@dferber90
Copy link
Contributor Author

dferber90 commented Oct 28, 2021

It seems as if one of these is the root cause:

res.setHeader(key, value.split(', '))

result[key] = value.split(', ')

@dferber90
Copy link
Contributor Author

dferber90 commented Oct 28, 2021

@styfle It seems as if this caused by #30288, specifically in d31615b. It's hard for me to follow why Set-Cookie needs the special treatment added there. I could undo it, but I don't know the side effects without knowing the reason why it was added.

Splitting based on , leads to wrong behaviour with cookies that have an Expires like
foo=bar; Max-Age=0; Path=/; Expires=Wed, 27 Oct 2021 11:17:49 GMT; SameSite=Lax as that contains a ,.

Since the middleware automatically sets Expires when maxAge is given, this is affecting cookies added through response.cookie() from the middleware that either have maxAge or expires set, like response.cookie(name, value, { maxAge: 100 }):

if (opts.maxAge) {
opts.expires = new Date(Date.now() + opts.maxAge)
opts.maxAge /= 1000
}

Could you give some context of why it's necessary, or can we maybe just drop the .split(', ')?


Edit: Oh it's there because calling append with the same key multiple times leads to concatenation with , :

const headers = new Headers()
headers.append("set-cookie", "a")
headers.append("set-cookie", "b")
headers.get("set-cookie") // -> 'a, b'

Unfortunately we still can't distinguish between header values genuinely containing , and header values concatenated with , because the header contains multiple values :( Seems like a limitation of the Headers class itself.


References:

Origin servers SHOULD NOT fold multiple Set-Cookie header fields into
a single header field. The usual mechanism for folding HTTP headers
fields (i.e., as defined in [RFC2616]) might change the semantics of
the Set-Cookie header field because the %x2C (",") character is used
by Set-Cookie in a way that conflicts with such folding.
https://www.rfc-editor.org/rfc/rfc6265#section-6.1

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma. The order in which header fields with the same field-name are received is therefore significant to the interpretation of the combined field value, and thus a proxy MUST NOT change the order of these field values when a message is forwarded.
https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html

Note: When Header values are iterated over, they are automatically sorted in lexicographical order, and values from duplicate header names are combined.
https://developer.mozilla.org/en-US/docs/Web/API/Headers

Note: In practice, the "Set-Cookie" header field ([RFC6265]) often
appears multiple times in a response message and does not use the
list syntax, violating the above requirements on multiple header
fields with the same name. Since it cannot be combined into a
single field-value, recipients ought to handle "Set-Cookie" as a
special case while processing header fields.
https://www.rfc-editor.org/rfc/rfc7230

@dferber90
Copy link
Contributor Author

To sum up my findings: RFC 6265 says that the Set-Cookie header should not be folded (multiple values should not get concatenated with ,) and should instead persist as multiple Set-Cookie headers. Unfortunately Headers folds it, so we end up with a broken Set-Cookie header.

The header looks like a=1; Expires=Wed, 27 Oct 2021 11:17:49 GMT, b=2; Expires=Thu, 28 Oct 2021 11:17:49 GMT which then breaks when split on , .

This can also happen to other headers containing a , in their original value.

@karaggeorge
Copy link
Contributor

Just to add some more context, the native web Headers implementation doesn't even allow setting/getting some forbidden header names:

Which include Cookie, Cookie2, Set-Cookie, Set-Cookie2

My guess is that CF workers implement their own version of Headers to allow those values. It also adds some new methods on the Headers object like getAll.

They probably did not take the RFC above into consideration when "patching" those forbidden headers?

Relevant issue: whatwg/fetch#973

Seems like that's why they proposed adding getAll in the first place, and it was even suggested to just handle Set-Cookie separately

@karaggeorge
Copy link
Contributor

We could also look into utilizing this library which handles the splitting correctly: https://www.npmjs.com/package/set-cookie-parser#splitcookiestringcombinedsetcookieheader

@kodiakhq kodiakhq bot closed this as completed in #30560 Oct 28, 2021
kodiakhq bot pushed a commit that referenced this issue Oct 28, 2021
## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

Fixes #30430 

There's some more discussion in the issue, but in summary:
- web `Headers` implementation combines all header values with `', '`
- For `Set-Cookie` headers, you're supposed to set them as separate values, not combine them
- web `Headers` forbids the use of `Cookie`, `Set-Cookie` and some more headers, so they don't have custom implementation for those, and still joins them with `,`
- We currently just split them using `split(',')`, but this breaks when the header contains a date (expires, max-age) that also includes a `,`

I used this method to split the Set-Cookie header properly: https://www.npmjs.com/package/set-cookie-parser#splitcookiestringcombinedsetcookieheader as suggested [here](whatwg/fetch#973 (comment))

I didn't add it as a dependency, since we only needed that one method and I wasn't sure what the process is for adding dependencies, so I just added the method in the middleware utils
dferber90 added a commit to happykit/flags that referenced this issue Nov 1, 2021
response.cookie was originally broken when Expires was present on the cookie: vercel/next.js#30430

This will be fixed in Next.js v12.0.2, so we use the canary of that for now, and the release of happykit might have to be a breaking change since we then no longer support older Next.js versions theoretically.

We need to list next >=12.0.2 as a peerDependency once v12.0.2 is out.
dferber90 added a commit to happykit/flags that referenced this issue Dec 4, 2021
response.cookie was originally broken when Expires was present on the cookie: vercel/next.js#30430

This will be fixed in Next.js v12.0.2, so we use the canary of that for now, and the release of happykit might have to be a breaking change since we then no longer support older Next.js versions theoretically.

We need to list next >=12.0.2 as a peerDependency once v12.0.2 is out.
dferber90 added a commit to happykit/flags that referenced this issue Dec 4, 2021
response.cookie was originally broken when Expires was present on the cookie: vercel/next.js#30430

This will be fixed in Next.js v12.0.2, so we use the canary of that for now, and the release of happykit might have to be a breaking change since we then no longer support older Next.js versions theoretically.

We need to list next >=12.0.2 as a peerDependency once v12.0.2 is out.
dferber90 added a commit to happykit/flags that referenced this issue Dec 4, 2021
* experiment: use from npm so we can use next 12 middleware

* use local @happykit/flags

* use @happykit/flags through workspace

* use req.cookies on server

* experimental middleware support

* add middleware pages for all variants

* move Content to components

* Content -> EdgeFunctionContent

* bring back dynamic recompliation of package

upgrades @preconstruct/next to a version supporting Next.js 12

* use response.cookie

response.cookie was originally broken when Expires was present on the cookie: vercel/next.js#30430

This will be fixed in Next.js v12.0.2, so we use the canary of that for now, and the release of happykit might have to be a breaking change since we then no longer support older Next.js versions theoretically.

We need to list next >=12.0.2 as a peerDependency once v12.0.2 is out.

* use getCookie

* update dependencies

* pre -> code

* use differt header access methods

* switch prebuild to preconstruct build

* use next.js v12.0.2

* upgrade dependencies

* add @tailwindui/react

* upgrade @babel/preset-env

* disable dynamic recompilation

Did not work, so reverting back to triggering package builds manually.

* add reload button to middleware

* rename happykit.config to flags.config

* increase maxAge

* add flagBag.cookie to getEdgeFlags

* avoid eval/new Function warning

Middleware does not accept eval or new Function, but the way in which we create our custom errors uses that under the hood. So we need to throw native errors directly without extending the error class.

* keep bottom bar on bottom

even with short content like on the middleware demo page

* list middleware support as key feature

* add getEdgeFlags to README

* remove InvalidConfigurationError

* reenable preconstruct dev

* Revert "reenable preconstruct dev"

This reverts commit 2171933.

* recreate cookieOptions object on every request

This avoids vercel/next.js#31666

* explain middleware behaviour in EdgeFunctionContent

* consistently use flags.config

* make flagBag.cookie iterable

* Revert "make flagBag.cookie iterable"

This reverts commit 77bfb6f.

* reword key features

* remove jest-expect-message

* add edge tests

* v2.0.0
@balazsorban44
Copy link
Member

This issue has been automatically locked due to no recent activity. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.

@vercel vercel locked as resolved and limited conversation to collaborators Jan 27, 2022
natew pushed a commit to natew/next.js that referenced this issue Feb 16, 2022
## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

Fixes vercel#30430 

There's some more discussion in the issue, but in summary:
- web `Headers` implementation combines all header values with `', '`
- For `Set-Cookie` headers, you're supposed to set them as separate values, not combine them
- web `Headers` forbids the use of `Cookie`, `Set-Cookie` and some more headers, so they don't have custom implementation for those, and still joins them with `,`
- We currently just split them using `split(',')`, but this breaks when the header contains a date (expires, max-age) that also includes a `,`

I used this method to split the Set-Cookie header properly: https://www.npmjs.com/package/set-cookie-parser#splitcookiestringcombinedsetcookieheader as suggested [here](whatwg/fetch#973 (comment))

I didn't add it as a dependency, since we only needed that one method and I wasn't sure what the process is for adding dependencies, so I just added the method in the middleware utils
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
bug Issue was opened via the bug report template.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants