Skip to content
Merged
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
33 changes: 1 addition & 32 deletions packages/playwright-core/src/server/cookieStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { isLocalHostname, kMaxCookieExpiresDateInSeconds } from './network';
import { kMaxCookieExpiresDateInSeconds } from './network';

import type * as channels from './channels';

Expand All @@ -28,17 +28,6 @@ export class Cookie {
return this._raw.name;
}

// https://datatracker.ietf.org/doc/html/rfc6265#section-5.4
matches(url: URL): boolean {
if (this._raw.secure && (url.protocol !== 'https:' && !isLocalHostname(url.hostname)))
return false;
if (!domainMatches(url.hostname, this._raw.domain))
return false;
if (!pathMatches(url.pathname, this._raw.path))
return false;
return true;
}

_equals(other: Cookie) {
return this._raw.name === other._raw.name &&
this._raw.domain === other._raw.domain &&
Expand Down Expand Up @@ -209,23 +198,3 @@ export function parseRawCookie(header: string): RawCookie | null {
}
return cookie;
}

export function domainMatches(value: string, domain: string): boolean {
if (value === domain)
return true;
// Only strict match is allowed if domain doesn't start with '.' (host-only-flag is true in the spec)
if (!domain.startsWith('.'))
return false;
value = '.' + value;
return value.endsWith(domain);
}

function pathMatches(value: string, path: string): boolean {
if (value === path)
return true;
if (!value.endsWith('/'))
value = value + '/';
if (!path.endsWith('/'))
path = path + '/';
return value.startsWith(path);
}
10 changes: 3 additions & 7 deletions packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ import { monotonicTime } from '@isomorphic/time';
import { createProxyAgent, flattenAggregateError, happyEyeballsOptions } from '@utils/network';
import { getUserAgent } from './userAgent';
import { BrowserContext, findMatchingHttpCredentials, verifyClientCertificates } from './browserContext';
import { Cookie, CookieStore, domainMatches, parseRawCookie } from './cookieStore';
import { CookieStore, parseRawCookie } from './cookieStore';
import { MultipartFormData } from './formData';
import { cookieMatchesClearFilter, filterCookies, rewriteCookies } from './network';
import { cookieMatchesClearFilter, domainMatches, filterCookies, rewriteCookies } from './network';
import { TargetClosedError } from './errors';
import { SdkObject } from './instrumentation';
import { isAbortError } from './progress';
Expand Down Expand Up @@ -287,11 +287,7 @@ export abstract class APIRequestContext extends SdkObject {
private async _updateRequestCookieHeader(progress: Progress, url: URL, headers: HeadersObject) {
if (getHeader(headers, 'cookie') !== undefined)
return;
const contextCookies = await this.cookies(progress, [url.toString()]);
// Browser context returns cookies with domain matching both .example.com and
// example.com. Those without leading dot are only sent when domain is strictly
// matching example.com, but not for sub.example.com.
const cookies = contextCookies.filter(c => new Cookie(c).matches(url));
const cookies = await this.cookies(progress, [url.toString()]);
if (cookies.length) {
const valueArray = cookies.map(c => `${c.name}=${c.value}`);
setHeader(headers, 'cookie', valueArray.join('; '));
Expand Down
47 changes: 34 additions & 13 deletions packages/playwright-core/src/server/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,43 @@ export function filterCookies(cookies: channels.NetworkCookie[], urls: string[])
return cookies.filter(c => {
if (!parsedURLs.length)
return true;
for (const parsedURL of parsedURLs) {
let domain = c.domain;
if (!domain.startsWith('.'))
domain = '.' + domain;
if (!('.' + parsedURL.hostname).endsWith(domain))
continue;
if (!parsedURL.pathname.startsWith(c.path))
continue;
if (parsedURL.protocol !== 'https:' && !isLocalHostname(parsedURL.hostname) && c.secure)
continue;
return true;
}
return false;
return parsedURLs.some(parsedURL => cookieMatchesURL(c, parsedURL));
});
}

// Whether the cookie is sent with a request to the given url.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.4
function cookieMatchesURL(cookie: channels.NetworkCookie, url: URL): boolean {
if (!domainMatches(url.hostname, cookie.domain))
return false;
if (!pathMatches(url.pathname, cookie.path))
return false;
if (cookie.secure && url.protocol !== 'https:' && !isLocalHostname(url.hostname))
return false;
return true;
}

// Cookie domain without a leading dot denotes a host-only cookie, see host-only-flag in
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
// Such a cookie requires the host to be identical to the domain, others follow domain-match from
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
export function domainMatches(hostname: string, cookieDomain: string): boolean {
if (hostname === cookieDomain)
return true;
if (!cookieDomain.startsWith('.'))
return false;
return ('.' + hostname).endsWith(cookieDomain);
}

// Implements path-match from https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
function pathMatches(requestPath: string, cookiePath: string): boolean {
if (requestPath === cookiePath)
return true;
if (!requestPath.startsWith(cookiePath))
return false;
return cookiePath.endsWith('/') || requestPath[cookiePath.length] === '/';
}

// Mirrors Chromium's net::IsLocalhost(): localhost, *.localhost, 127.0.0.0/8 and [::1].
export function isLocalHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
Expand Down
19 changes: 19 additions & 0 deletions tests/library/browsercontext-cookies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,25 @@ it('should work with subdomain cookie', async ({ context, browserName, isWindows
}]);
});

it('should only return cookies that apply to the url', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/43078' },
}, async ({ context }) => {
await context.addCookies([
{ name: 'hostonly', value: 'v', domain: 'foo.com', path: '/api' },
{ name: 'subdomains', value: 'v', domain: '.foo.com', path: '/api' },
{ name: 'slash', value: 'v', domain: 'foo.com', path: '/foo/' },
]);
const names = async (url: string) => (await context.cookies(url)).map(c => c.name).sort();
expect(await names('http://foo.com/api')).toEqual(['hostonly', 'subdomains']);
expect(await names('http://foo.com/api/x')).toEqual(['hostonly', 'subdomains']);
expect(await names('http://foo.com/apiv2')).toEqual([]);
expect(await names('http://sub.foo.com/api')).toEqual(['subdomains']);
expect(await names('http://notfoo.com/api')).toEqual([]);
expect(await names('http://foo.com/foo')).toEqual([]);
expect(await names('http://foo.com/foo/')).toEqual(['slash']);
expect(await names('http://foo.com/foo/bar')).toEqual(['slash']);
});

it('should return cookies with empty value', async ({ context, page, server }) => {
server.setRoute('/empty.html', (req, res) => {
res.setHeader('Set-Cookie', 'name=;Path=/');
Expand Down
45 changes: 44 additions & 1 deletion tests/library/global-fetch-cookie.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ it('cookies should return cookies filtered by urls', async ({ request, server })
await request.addCookies([
{ name: 'a', value: 'b', domain: 'localhost', path: '/' },
{ name: 'c', value: 'd', domain: 'localhost', path: '/input' },
{ name: 'e', value: 'f', domain: 'one.com', path: '/' },
{ name: 'e', value: 'f', domain: '.one.com', path: '/' },
{ name: 'g', value: 'h', domain: 'two.com', path: '/', secure: true },
]);
expect((await request.cookies()).map(c => c.name)).toEqual(['a', 'c', 'e', 'g']);
Expand All @@ -95,6 +95,49 @@ it('cookies should return cookies filtered by urls', async ({ request, server })
expect(await request.cookies('http://other.com/')).toEqual([]);
});

it('cookies should only return cookies that apply to the url', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/43078' },
}, async ({ request }) => {
await request.addCookies([
{ name: 'hostonly', value: 'v', domain: 'one.com', path: '/api' },
{ name: 'subdomains', value: 'v', domain: '.one.com', path: '/api' },
{ name: 'slash', value: 'v', domain: 'one.com', path: '/foo/' },
]);
const names = async (url: string) => (await request.cookies(url)).map(c => c.name);
expect(await names('http://one.com/api')).toEqual(['hostonly', 'subdomains']);
expect(await names('http://one.com/api/x')).toEqual(['hostonly', 'subdomains']);
expect(await names('http://one.com/apiv2')).toEqual([]);
expect(await names('http://sub.one.com/api')).toEqual(['subdomains']);
expect(await names('http://notone.com/api')).toEqual([]);
expect(await names('http://one.com/foo')).toEqual([]);
expect(await names('http://one.com/foo/')).toEqual(['slash']);
expect(await names('http://one.com/foo/bar')).toEqual(['slash']);
});

it('should only send cookies that apply to the url', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/43078' },
}, async ({ request, server }) => {
server.setRoute('/setcookie.html', (req, res) => {
res.setHeader('Set-Cookie', ['hostonly=v; path=/api', 'subdomains=v; domain=one.com; path=/api', 'slash=v; path=/foo/']);
res.end();
});
await request.get(`http://one.com:${server.PORT}/setcookie.html`, { __testHookLookup } as any);
const sent = async (host: string, path: string) => {
const [serverRequest] = await Promise.all([
server.waitForRequest(path),
request.get(`http://${host}:${server.PORT}${path}`, { __testHookLookup } as any),
]);
return serverRequest.headers.cookie;
};
expect(await sent('one.com', '/api')).toBe('hostonly=v; subdomains=v');
expect(await sent('one.com', '/api/x')).toBe('hostonly=v; subdomains=v');
expect(await sent('one.com', '/apiv2')).toBe(undefined);
expect(await sent('sub.one.com', '/api')).toBe('subdomains=v');
expect(await sent('one.com', '/foo')).toBe(undefined);
expect(await sent('one.com', '/foo/')).toBe('slash=v');
expect(await sent('one.com', '/foo/bar')).toBe('slash=v');
});

it('cookies should include cookies from Set-Cookie header', async ({ request, server }) => {
server.setRoute('/setcookie.html', (req, res) => {
res.setHeader('Set-Cookie', ['a=b; HttpOnly; SameSite=Strict', 'c=d; path=/input']);
Expand Down
Loading