diff --git a/packages/isomorphic/headers.ts b/packages/isomorphic/headers.ts index e228e5312f230..0ffad2aa6c134 100644 --- a/packages/isomorphic/headers.ts +++ b/packages/isomorphic/headers.ts @@ -17,6 +17,53 @@ type HeadersArray = { name: string, value: string }[]; type HeadersObject = { [key: string]: string }; +export const singleValuedHeaders = new Set([ + 'authorization', + 'content-disposition', + 'content-length', + 'content-range', + 'content-type', + 'date', + 'etag', + 'expires', + 'host', + 'if-modified-since', + 'if-unmodified-since', + 'last-modified', + 'location', + 'proxy-authorization', + 'referer', + 'retry-after', + 'server', + 'user-agent', +]); + +export function splitSetCookieString(values: string, separator: string): string[] { + if (separator !== ',') + return values.split(separator).map(v => v.trim()).filter(Boolean); + // macOS WebKit joins Set-Cookie headers with ', '. + // However, cookie 'Expires' attribute values contain commas (e.g. 'Expires=Wed, 21 Oct 2026 07:28:00 GMT'). + // We split by comma only when it is not part of an Expires attribute. + const cookies: string[] = []; + let current = ''; + for (let i = 0; i < values.length; i++) { + if (values[i] === ',') { + if (/Expires=[^;]*$/i.test(current)) { + current += ','; + continue; + } + if (current.trim()) + cookies.push(current.trim()); + current = ''; + } else { + current += values[i]; + } + } + if (current.trim()) + cookies.push(current.trim()); + return cookies; +} + export function headersObjectToArray(headers: HeadersObject, separator?: string, setCookieSeparator?: string): HeadersArray { if (!setCookieSeparator) setCookieSeparator = separator; @@ -25,10 +72,18 @@ export function headersObjectToArray(headers: HeadersObject, separator?: string, const values = headers[name]; if (values === undefined) continue; + const lowerName = name.toLowerCase(); if (separator) { - const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator; - for (const value of values.split(sep!)) - result.push({ name, value: value.trim() }); + if (lowerName === 'set-cookie') { + const cookies = splitSetCookieString(values, setCookieSeparator); + for (const cookie of cookies) + result.push({ name, value: cookie }); + } else if (singleValuedHeaders.has(lowerName)) { + result.push({ name, value: values.trim() }); + } else { + for (const value of values.split(separator)) + result.push({ name, value: value.trim() }); + } } else { result.push({ name, value: values }); } diff --git a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts index e17afd5c45e69..1a2da5c4a689a 100644 --- a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts +++ b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { singleValuedHeaders } from '@isomorphic/headers'; import { eventsHelper } from '@utils/eventsHelper'; import * as network from '../network'; @@ -280,10 +281,18 @@ class FFRouteImpl implements network.RouteDelegate { function parseMultivalueHeaders(headers: HeadersArray) { const result: HeadersArray = []; for (const header of headers) { - const separator = header.name.toLowerCase() === 'set-cookie' ? '\n' : ','; - const tokens = header.value.split(separator).map(s => s.trim()); - for (const token of tokens) - result.push({ name: header.name, value: token }); + const lowerName = header.name.toLowerCase(); + if (lowerName === 'set-cookie') { + const tokens = header.value.split('\n').map(s => s.trim()); + for (const token of tokens) + result.push({ name: header.name, value: token }); + } else if (singleValuedHeaders.has(lowerName)) { + result.push({ name: header.name, value: header.value.trim() }); + } else { + const tokens = header.value.split(',').map(s => s.trim()); + for (const token of tokens) + result.push({ name: header.name, value: token }); + } } return result; } diff --git a/tests/library/unit/headers.spec.ts b/tests/library/unit/headers.spec.ts new file mode 100644 index 0000000000000..60beea4245d4d --- /dev/null +++ b/tests/library/unit/headers.spec.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as it, expect } from '@playwright/test'; +import { headersObjectToArray, splitSetCookieString, singleValuedHeaders } from '../../../packages/isomorphic/headers'; + +it('should not split single-valued headers on comma', () => { + const headers = { + 'Date': 'Fri, 11 Sep 2026 05:27:07 GMT', + 'Last-Modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'Expires': 'Thu, 01 Dec 2026 16:00:00 GMT', + 'Retry-After': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'Content-Type': 'text/html; charset=utf-8', + 'Location': 'https://example.com/path,with,comma', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64), TestBot/1.0', + 'Vary': 'Accept-Encoding, User-Agent', + 'Cache-Control': 'no-cache, no-store', + }; + + const result = headersObjectToArray(headers, ','); + + expect(result.filter(h => h.name === 'Date')).toEqual([ + { name: 'Date', value: 'Fri, 11 Sep 2026 05:27:07 GMT' } + ]); + expect(result.filter(h => h.name === 'Last-Modified')).toEqual([ + { name: 'Last-Modified', value: 'Wed, 21 Oct 2026 07:28:00 GMT' } + ]); + expect(result.filter(h => h.name === 'Expires')).toEqual([ + { name: 'Expires', value: 'Thu, 01 Dec 2026 16:00:00 GMT' } + ]); + expect(result.filter(h => h.name === 'Retry-After')).toEqual([ + { name: 'Retry-After', value: 'Wed, 21 Oct 2026 07:28:00 GMT' } + ]); + expect(result.filter(h => h.name === 'Content-Type')).toEqual([ + { name: 'Content-Type', value: 'text/html; charset=utf-8' } + ]); + expect(result.filter(h => h.name === 'Location')).toEqual([ + { name: 'Location', value: 'https://example.com/path,with,comma' } + ]); + expect(result.filter(h => h.name === 'User-Agent')).toEqual([ + { name: 'User-Agent', value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64), TestBot/1.0' } + ]); + + // Multi-valued headers should still be split + expect(result.filter(h => h.name === 'Vary')).toEqual([ + { name: 'Vary', value: 'Accept-Encoding' }, + { name: 'Vary', value: 'User-Agent' }, + ]); + expect(result.filter(h => h.name === 'Cache-Control')).toEqual([ + { name: 'Cache-Control', value: 'no-cache' }, + { name: 'Cache-Control', value: 'no-store' }, + ]); +}); + +it('should handle Set-Cookie splitting correctly on macOS WebKit comma separator', () => { + // Single cookie with Expires containing a comma should stay intact + expect(splitSetCookieString('sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/', ',')).toEqual([ + 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/' + ]); + + // Multiple cookies joined by comma with Expires should be split properly + const multiple = 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/, token=xyz; Secure, theme=dark'; + expect(splitSetCookieString(multiple, ',')).toEqual([ + 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/', + 'token=xyz; Secure', + 'theme=dark', + ]); + + // With standard newline separator + const newlineCookies = 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT\ntoken=xyz'; + expect(splitSetCookieString(newlineCookies, '\n')).toEqual([ + 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + 'token=xyz', + ]); +}); + +it('should contain all standard single-valued headers in set', () => { + expect(singleValuedHeaders.has('date')).toBe(true); + expect(singleValuedHeaders.has('last-modified')).toBe(true); + expect(singleValuedHeaders.has('expires')).toBe(true); + expect(singleValuedHeaders.has('if-modified-since')).toBe(true); + expect(singleValuedHeaders.has('if-unmodified-since')).toBe(true); + expect(singleValuedHeaders.has('retry-after')).toBe(true); + expect(singleValuedHeaders.has('content-type')).toBe(true); + expect(singleValuedHeaders.has('location')).toBe(true); +}); diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 9ec3e8eb6121d..7ff8f8cc072e6 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -260,6 +260,36 @@ it('should report multiple set-cookie headers', async ({ page, server, isElectro expect(await response.headerValues('set-cookie')).toEqual(['a=b', 'c=d']); }); +it('should not split single-valued and http-date headers on comma', async ({ page, server }) => { + server.setRoute('/headers', (req, res) => { + res.writeHead(200, { + 'Date': 'Fri, 11 Sep 2026 05:27:07 GMT', + 'Last-Modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'Expires': 'Thu, 01 Dec 2026 16:00:00 GMT', + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.end('ok'); + }); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse('**/*'), + page.evaluate(() => fetch('/headers')) + ]); + const headers = await response.headersArray(); + const lastModified = headers.filter(h => h.name.toLowerCase() === 'last-modified'); + expect(lastModified).toHaveLength(1); + expect(lastModified[0].value).toBe('Wed, 21 Oct 2026 07:28:00 GMT'); + + const date = headers.filter(h => h.name.toLowerCase() === 'date'); + expect(date).toHaveLength(1); + expect(date[0].value).toBe('Fri, 11 Sep 2026 05:27:07 GMT'); + + const expires = headers.filter(h => h.name.toLowerCase() === 'expires'); + expect(expires).toHaveLength(1); + expect(expires[0].value).toBe('Thu, 01 Dec 2026 16:00:00 GMT'); +}); + it('should behave the same way for headers and allHeaders', async ({ page, server, browserName, platform }) => { it.skip(browserName === 'webkit' && platform === 'win32', 'libcurl does not support non-set-cookie multivalue headers'); server.setRoute('/headers', (req, res) => {