Skip to content
Closed
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
21 changes: 20 additions & 1 deletion packages/isomorphic/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function headersObjectToArray(headers: HeadersObject, separator?: string,
continue;
if (separator) {
const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator;
for (const value of values.split(sep!))
for (const value of splitHeaderValue(name, values, sep!))
result.push({ name, value: value.trim() });
} else {
result.push({ name, value: values });
Expand All @@ -42,3 +42,22 @@ export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean):
result[lowerCase ? name.toLowerCase() : name] = value;
return result;
}

export function splitHeaderValue(name: string, value: string, separator: string): string[] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The separator === ',' guard makes the coupling between the name switch and the separator implicit. Suggest keeping plain split('\n') for the raw-header paths and giving the comma path its own splitCommaSeparatedHeader(name, value).

if (separator === ',') {
switch (name.toLowerCase()) {
case 'date':

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's use Chromium's non-coalescing list (HttpUtil::IsNonCoalescingHeader) instead: date, expires, last-modified, location, retry-after, set-cookie, www-authenticate, proxy-authenticate, strict-transport-security.

location (commas in query strings) and the auth challenges are real breakages that this list misses. if-modified-since, if-unmodified-since and if-range are request headers; every caller passing a separator handles response headers, so they are dead entries.

case 'expires':
case 'last-modified':
case 'if-modified-since':
case 'if-unmodified-since':
case 'if-range':
case 'retry-after':
return [value];
case 'set-cookie':
// Only split before a cookie pair, not the date following an Expires comma.
return value.split(/,(?=\s*[!#$%&'*+\-.^_`|~\da-z]+=)/i);
}
}
return value.split(separator);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

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

Expand Down Expand Up @@ -281,7 +282,7 @@ 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());
const tokens = splitHeaderValue(header.name, header.value, separator).map(s => s.trim());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For Firefox we don't need to guess. nsIHttpChannel.visitOriginalResponseHeaders returns headers unmerged, in the order received from the peer. Synthesized headers from route.fulfill go through ParseHeaderLine -> SetHeaderFromNet, so they are visited too. Switching responseHead() in juggler NetworkObserver.js to it makes parseMultivalueHeaders unnecessary and also fixes WWW-Authenticate/Proxy-Authenticate, which Gecko merges with \n and we currently split on comma.

That needs a Firefox roll, so fine to land the heuristic first, but let's not treat this as the Firefox fix.

for (const token of tokens)
result.push({ name: header.name, value: token });
}
Expand Down
54 changes: 54 additions & 0 deletions tests/page/page-network-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,35 @@ it('should report all headers', async ({ page, server, browserName, platform, is
expect(actualHeaders).toEqual(expectedHeaders);
});

it('should preserve commas in HTTP date headers', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42687' },
}, async ({ page, server }) => {
const date = 'Wed, 21 Oct 2015 07:28:00 GMT';
const expectedHeaders = {
'Date': date,
'Expires': date,
'Last-Modified': date,
'If-Modified-Since': date,
'If-Unmodified-Since': date,
'If-Range': date,
'Retry-After': date,
};
server.setRoute('/headers', (req, res) => {
res.writeHead(200, expectedHeaders);
res.end('ok');
});

const response = await page.goto(server.PREFIX + '/headers');
const headers = await response.headersArray();
const allHeaders = await response.allHeaders();
for (const [name, value] of Object.entries(expectedHeaders)) {
expect(headers.filter(header => header.name.toLowerCase() === name.toLowerCase()).map(header => header.value)).toEqual([value]);
expect(await response.headerValues(name)).toEqual([value]);
expect(await response.headerValue(name)).toBe(value);
expect(allHeaders[name.toLowerCase()]).toBe(value);
}
});

it('should report multiple set-cookie headers', async ({ page, server, isElectron, browserMajorVersion }) => {
it.skip(isElectron && browserMajorVersion < 99, 'This needs Chromium >= 99');

Expand All @@ -260,6 +289,31 @@ it('should report multiple set-cookie headers', async ({ page, server, isElectro
expect(await response.headerValues('set-cookie')).toEqual(['a=b', 'c=d']);
});

it('should preserve expires dates in set-cookie headers', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42687' },
}, async ({ page, server, isElectron, browserMajorVersion }) => {
it.skip(isElectron && browserMajorVersion < 99, 'This needs Chromium >= 99');

const cookies = [
'first=value; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/',
'second=value; Expires=Wed, 21 Oct 2015 07:28:00 GMT',
'third=value',
];
for (const expectedCookies of [[cookies[0]], [cookies[1]], cookies]) {
server.setRoute('/headers', (req, res) => {
res.writeHead(200, { 'Set-Cookie': expectedCookies });
res.end('ok');
});

const response = await page.goto(server.PREFIX + '/headers');
const headers = await response.headersArray();
expect(headers.filter(({ name }) => name.toLowerCase() === 'set-cookie').map(({ value }) => value)).toEqual(expectedCookies);
expect(await response.headerValues('set-cookie')).toEqual(expectedCookies);
expect(await response.headerValue('set-cookie')).toBe(expectedCookies.join('\n'));
expect((await response.allHeaders())['set-cookie']).toBe(expectedCookies.join('\n'));
}
});

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
Loading