Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions packages/isomorphic/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
}
Expand Down
17 changes: 13 additions & 4 deletions packages/playwright-core/src/server/firefox/ffNetworkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

import { singleValuedHeaders } from '@isomorphic/headers';
import { eventsHelper } from '@utils/eventsHelper';
import * as network from '../network';

Expand Down Expand Up @@ -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;
}
99 changes: 99 additions & 0 deletions tests/library/unit/headers.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
30 changes: 30 additions & 0 deletions tests/page/page-network-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down