From 591ddcd1bf6b17292450c799aae1c4b27f33a0ef Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 6 Oct 2026 15:51:41 +0100 Subject: [PATCH] fix(cookies): follow RFC 6265 domain and path matching in cookies(urls) Fixes: https://github.com/microsoft/playwright/issues/43078 --- .../playwright-core/src/server/cookieStore.ts | 33 +------------ packages/playwright-core/src/server/fetch.ts | 10 ++-- .../playwright-core/src/server/network.ts | 47 ++++++++++++++----- tests/library/browsercontext-cookies.spec.ts | 19 ++++++++ tests/library/global-fetch-cookie.spec.ts | 45 +++++++++++++++++- 5 files changed, 101 insertions(+), 53 deletions(-) diff --git a/packages/playwright-core/src/server/cookieStore.ts b/packages/playwright-core/src/server/cookieStore.ts index 5d54b6032b789..35545bf933148 100644 --- a/packages/playwright-core/src/server/cookieStore.ts +++ b/packages/playwright-core/src/server/cookieStore.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { isLocalHostname, kMaxCookieExpiresDateInSeconds } from './network'; +import { kMaxCookieExpiresDateInSeconds } from './network'; import type * as channels from './channels'; @@ -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 && @@ -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); -} diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 08c8c8fa67c65..31cc9b94d9fd1 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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'; @@ -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('; ')); diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 7c7cad66a9e4b..44fc0cc48db25 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -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); diff --git a/tests/library/browsercontext-cookies.spec.ts b/tests/library/browsercontext-cookies.spec.ts index d34104d58be5d..aabcffc8190d9 100644 --- a/tests/library/browsercontext-cookies.spec.ts +++ b/tests/library/browsercontext-cookies.spec.ts @@ -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=/'); diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index 8503c13784c08..f6689d5ee4068 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -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']); @@ -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']);