Skip to content

Commit

Permalink
Split Set-Cookie header correctly (#30560)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
karaggeorge committed Oct 28, 2021
1 parent 5b4ad4a commit 450552d
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 12 deletions.
13 changes: 6 additions & 7 deletions packages/next/server/next-server.ts
Expand Up @@ -108,6 +108,7 @@ import type { FetchEventResult } from './web/types'
import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin'
import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url'
import type { ParsedUrl } from '../shared/lib/router/utils/parse-url'
import { toNodeHeaders } from './web/utils'

const getCustomRouteMatcher = pathMatch(true)

Expand Down Expand Up @@ -1168,13 +1169,11 @@ export default class Server {

result.response.headers.delete('x-middleware-next')

for (const [key, value] of result.response.headers.entries()) {
if (key !== 'content-encoding') {
if (key.toLowerCase() === 'set-cookie') {
res.setHeader(key, value.split(', '))
} else {
res.setHeader(key, value)
}
for (const [key, value] of Object.entries(
toNodeHeaders(result.response.headers)
)) {
if (key !== 'content-encoding' && value !== undefined) {
res.setHeader(key, value)
}
}

Expand Down
78 changes: 77 additions & 1 deletion packages/next/server/web/utils.ts
Expand Up @@ -39,9 +39,85 @@ export function toNodeHeaders(headers?: Headers): NodeHeaders {
for (const [key, value] of headers.entries()) {
result[key] = value
if (key.toLowerCase() === 'set-cookie') {
result[key] = value.split(', ')
result[key] = splitCookiesString(value)
}
}
}
return result
}

/*
Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas
that are within a single set-cookie field-value, such as in the Expires portion.
This is uncommon, but explicitly allowed - see https://tools.ietf.org/html/rfc2616#section-4.2
Node.js does this for every header *except* set-cookie - see https://github.com/nodejs/node/blob/d5e363b77ebaf1caf67cd7528224b651c86815c1/lib/_http_incoming.js#L128
React Native's fetch does this for *every* header, including set-cookie.
Based on: https://github.com/google/j2objc/commit/16820fdbc8f76ca0c33472810ce0cb03d20efe25
Credits to: https://github.com/tomball for original and https://github.com/chrusart for JavaScript implementation
*/
export function splitCookiesString(cookiesString: string) {
var cookiesStrings = []
var pos = 0
var start
var ch
var lastComma
var nextStart
var cookiesSeparatorFound

function skipWhitespace() {
while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) {
pos += 1
}
return pos < cookiesString.length
}

function notSpecialChar() {
ch = cookiesString.charAt(pos)

return ch !== '=' && ch !== ';' && ch !== ','
}

while (pos < cookiesString.length) {
start = pos
cookiesSeparatorFound = false

while (skipWhitespace()) {
ch = cookiesString.charAt(pos)
if (ch === ',') {
// ',' is a cookie separator if we have later first '=', not ';' or ','
lastComma = pos
pos += 1

skipWhitespace()
nextStart = pos

while (pos < cookiesString.length && notSpecialChar()) {
pos += 1
}

// currently special character
if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') {
// we found cookies separator
cookiesSeparatorFound = true
// pos is inside the next cookie, so back up and return it.
pos = nextStart
cookiesStrings.push(cookiesString.substring(start, lastComma))
start = pos
} else {
// in param ',' or param separator ';',
// we continue from that comma
pos = lastComma + 1
}
} else {
pos += 1
}
}

if (!cookiesSeparatorFound || pos >= cookiesString.length) {
cookiesStrings.push(cookiesString.substring(start, cookiesString.length))
}
}

return cookiesStrings
}
Expand Up @@ -22,7 +22,7 @@ export async function middleware(request, ev) {

// Ensure deep can append to this value
if (url.searchParams.get('cookie-me') === 'true') {
next.headers.append('set-cookie', 'chocochip')
next.headers.append('set-cookie', 'bar=chocochip')
}

// Sends a header
Expand Down
Expand Up @@ -4,6 +4,6 @@ export async function middleware(request, _event) {
const next = NextResponse.next()
next.headers.set('x-deep-header', 'valid')
next.headers.append('x-append-me', 'deep')
next.headers.append('set-cookie', 'oatmeal')
next.headers.append('set-cookie', 'foo=oatmeal')
return next
}
Expand Up @@ -8,7 +8,7 @@ export async function middleware(request) {
if (!bucket) {
bucket = Math.random() >= 0.5 ? 'a' : 'b'
const response = NextResponse.rewrite(`/rewrites/${bucket}`)
response.cookie('bucket', bucket)
response.cookie('bucket', bucket, { maxAge: 10000 })
return response
}

Expand Down
7 changes: 6 additions & 1 deletion test/integration/middleware-core/test/index.test.js
Expand Up @@ -115,6 +115,8 @@ function rewriteTests(locale = '') {
)
const html = await res.text()
const $ = cheerio.load(html)
// Set-Cookie header with Expires should not be split into two
expect(res.headers.raw()['set-cookie']).toHaveLength(1)
const bucket = getCookieFromResponse(res, 'bucket')
const expectedText = bucket === 'a' ? 'Welcome Page A' : 'Welcome Page B'
const browser = await webdriver(
Expand Down Expand Up @@ -334,7 +336,10 @@ function responseTests(locale = '') {
expect(res.headers.get('x-nested-header')).toBe('valid')
expect(res.headers.get('x-deep-header')).toBe('valid')
expect(res.headers.get('x-append-me')).toBe('top, deep')
expect(res.headers.raw()['set-cookie']).toEqual(['chocochip', 'oatmeal'])
expect(res.headers.raw()['set-cookie']).toEqual([
'bar=chocochip',
'foo=oatmeal',
])
})
}

Expand Down
143 changes: 143 additions & 0 deletions test/unit/split-cookies-string.test.ts
@@ -0,0 +1,143 @@
import { splitCookiesString } from 'next/dist/server/web/utils'
import cookie, { CookieSerializeOptions } from 'next/dist/compiled/cookie'

function generateCookies(
...cookieOptions: (CookieSerializeOptions & { name: string; value: string })[]
) {
const cookies = cookieOptions.map((opts) =>
cookie.serialize(opts.name, opts.value, opts)
)
return {
joined: cookies.join(', '),
expected: cookies,
}
}

describe('splitCookiesString', () => {
describe('with a single cookie', () => {
it('should parse a plain value', () => {
const { joined, expected } = generateCookies({
name: 'foo',
value: 'bar',
})
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse expires', () => {
const { joined, expected } = generateCookies({
name: 'foo',
value: 'bar',
expires: new Date(),
})
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse max-age', () => {
const { joined, expected } = generateCookies({
name: 'foo',
value: 'bar',
maxAge: 10,
})
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse with all the options', () => {
const { joined, expected } = generateCookies({
name: 'foo',
value: 'bar',
expires: new Date(Date.now() + 10 * 1000),
maxAge: 10,
domain: 'https://foo.bar',
httpOnly: true,
path: '/path',
sameSite: 'lax',
secure: true,
})
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})
})

describe('with a mutliple cookies', () => {
it('should parse a plain value', () => {
const { joined, expected } = generateCookies(
{
name: 'foo',
value: 'bar',
},
{
name: 'x',
value: 'y',
}
)
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse expires', () => {
const { joined, expected } = generateCookies(
{
name: 'foo',
value: 'bar',
expires: new Date(),
},
{
name: 'x',
value: 'y',
expires: new Date(),
}
)
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse max-age', () => {
const { joined, expected } = generateCookies(
{
name: 'foo',
value: 'bar',
maxAge: 10,
},
{
name: 'x',
value: 'y',
maxAge: 10,
}
)
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})

it('should parse with all the options', () => {
const { joined, expected } = generateCookies(
{
name: 'foo',
value: 'bar',
expires: new Date(Date.now() + 10 * 1000),
maxAge: 10,
domain: 'https://foo.bar',
httpOnly: true,
path: '/path',
sameSite: 'lax',
secure: true,
},
{
name: 'x',
value: 'y',
expires: new Date(Date.now() + 20 * 1000),
maxAge: 20,
domain: 'https://x.y',
httpOnly: true,
path: '/path',
sameSite: 'strict',
secure: true,
}
)
const result = splitCookiesString(joined)
expect(result).toEqual(expected)
})
})
})

1 comment on commit 450552d

@ijjk
Copy link
Member

@ijjk ijjk commented on 450552d Oct 28, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stats from current release

Default Build (Increase detected ⚠️)
General Overall decrease ✓
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
buildDuration 16.7s 17s ⚠️ +290ms
buildDurationCached 3.5s 3.4s -47ms
nodeModulesSize 207 MB 198 MB -8.74 MB
Page Load Tests Overall increase ✓
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
/ failed reqs 0 0
/ total time (seconds) 2.84 2.817 -0.02
/ avg req/sec 880.2 887.53 +7.33
/error-in-render failed reqs 0 0
/error-in-render total time (seconds) 1.33 1.33
/error-in-render avg req/sec 1879.62 1880.19 +0.57
Client Bundles (main, webpack, commons) Overall increase ⚠️
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
450.HASH.js gzip 179 B 179 B
framework-HASH.js gzip 42.2 kB 42.2 kB
main-HASH.js gzip 28 kB 28 kB ⚠️ +7 B
webpack-HASH.js gzip 1.45 kB 1.45 kB
Overall change 71.9 kB 71.9 kB ⚠️ +7 B
Legacy Client Bundles (polyfills)
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
polyfills-a4..dd70.js gzip 31 kB 31 kB
Overall change 31 kB 31 kB
Client Pages
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
_app-HASH.js gzip 1.23 kB 1.23 kB
_error-HASH.js gzip 194 B 194 B
amp-HASH.js gzip 312 B 312 B
css-HASH.js gzip 327 B 327 B
dynamic-HASH.js gzip 2.38 kB 2.38 kB
head-HASH.js gzip 350 B 350 B
hooks-HASH.js gzip 635 B 635 B
image-HASH.js gzip 4.44 kB 4.44 kB
index-HASH.js gzip 263 B 263 B
link-HASH.js gzip 1.87 kB 1.87 kB
routerDirect..HASH.js gzip 321 B 321 B
script-HASH.js gzip 383 B 383 B
withRouter-HASH.js gzip 318 B 318 B
334f979574ae..6f4.css gzip 106 B 106 B
Overall change 13.1 kB 13.1 kB
Client Build Manifests
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
_buildManifest.js gzip 459 B 459 B
Overall change 459 B 459 B
Rendered Page Sizes Overall decrease ✓
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
index.html gzip 535 B 534 B -1 B
link.html gzip 547 B 545 B -2 B
withRouter.html gzip 527 B 527 B
Overall change 1.61 kB 1.61 kB -3 B

Diffs

Diff for main-HASH.js
@@ -522,7 +522,7 @@
         document.getElementById("__NEXT_DATA__").textContent
       );
       window.__NEXT_DATA__ = data;
-      var version = "12.0.1";
+      var version = "12.0.2-canary.6";
       exports.version = version;
       var looseToArray = function(input) {
         return [].slice.call(input);
Diff for index.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script
Diff for link.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script
Diff for withRouter.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script

Default Build with SWC (Increase detected ⚠️)
General Overall decrease ✓
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
buildDuration 14.5s 14.6s ⚠️ +194ms
buildDurationCached 3.4s 3.5s ⚠️ +118ms
nodeModulesSize 207 MB 198 MB -8.74 MB
Page Load Tests Overall decrease ⚠️
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
/ failed reqs 0 0
/ total time (seconds) 2.819 3.066 ⚠️ +0.25
/ avg req/sec 886.76 815.51 ⚠️ -71.25
/error-in-render failed reqs 0 0
/error-in-render total time (seconds) 1.36 1.318 -0.04
/error-in-render avg req/sec 1837.56 1896.53 +58.97
Client Bundles (main, webpack, commons) Overall increase ⚠️
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
450.HASH.js gzip 179 B 179 B
framework-HASH.js gzip 42.2 kB 42.3 kB ⚠️ +87 B
main-HASH.js gzip 28.2 kB 28.2 kB ⚠️ +36 B
webpack-HASH.js gzip 1.43 kB 1.43 kB
Overall change 72 kB 72.1 kB ⚠️ +123 B
Legacy Client Bundles (polyfills)
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
polyfills-a4..dd70.js gzip 31 kB 31 kB
Overall change 31 kB 31 kB
Client Pages Overall increase ⚠️
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
_app-HASH.js gzip 1.22 kB 1.22 kB ⚠️ +2 B
_error-HASH.js gzip 180 B 180 B
amp-HASH.js gzip 305 B 305 B
css-HASH.js gzip 321 B 321 B
dynamic-HASH.js gzip 2.38 kB 2.38 kB ⚠️ +2 B
head-HASH.js gzip 342 B 342 B
hooks-HASH.js gzip 621 B 622 B ⚠️ +1 B
image-HASH.js gzip 4.46 kB 4.46 kB ⚠️ +1 B
index-HASH.js gzip 256 B 256 B
link-HASH.js gzip 1.9 kB 1.91 kB ⚠️ +1 B
routerDirect..HASH.js gzip 314 B 314 B
script-HASH.js gzip 375 B 375 B
withRouter-HASH.js gzip 309 B 309 B
334f979574ae..6f4.css gzip 106 B 106 B
Overall change 13.1 kB 13.1 kB ⚠️ +7 B
Client Build Manifests Overall increase ⚠️
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
_buildManifest.js gzip 459 B 460 B ⚠️ +1 B
Overall change 459 B 460 B ⚠️ +1 B
Rendered Page Sizes
vercel/next.js canary v12.0.1 vercel/next.js refs/heads/canary Change
index.html gzip 535 B 535 B
link.html gzip 547 B 547 B
withRouter.html gzip 529 B 529 B
Overall change 1.61 kB 1.61 kB

Diffs

Diff for main-HASH.js
@@ -522,7 +522,7 @@
         document.getElementById("__NEXT_DATA__").textContent
       );
       window.__NEXT_DATA__ = data;
-      var version = "12.0.1";
+      var version = "12.0.2-canary.6";
       exports.version = version;
       var looseToArray = function(input) {
         return [].slice.call(input);
Diff for index.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script
Diff for link.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script
Diff for withRouter.html
@@ -19,7 +19,7 @@
       defer=""
     ></script>
     <script
-      src="/_next/static/chunks/main-8e0014116b43e18a.js"
+      src="/_next/static/chunks/main-e90a98e737ce6e85.js"
       defer=""
     ></script>
     <script

Please sign in to comment.