diff --git a/docs/src/api/params.md b/docs/src/api/params.md index a31ea6047c81a..a1d7da840f083 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -549,7 +549,7 @@ Does not enforce fixed viewport, allows resizing window in the headed mode. ## context-option-clientCertificates - `clientCertificates` <[Array]<[Object]>> - - `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + - `origin` <[string]> Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - `certPath` ?<[path]> Path to the file with the certificate in PEM format. - `cert` ?<[Buffer]> Direct value of the certificate in PEM format. - `keyPath` ?<[path]> Path to the file with the private key in PEM format. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 0dd5a1d68ac45..13a374d9ca5db 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9740,7 +9740,8 @@ export interface Browser { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -14786,7 +14787,8 @@ export interface BrowserType { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -17490,7 +17492,8 @@ export interface APIRequest { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -21994,7 +21997,8 @@ export interface BrowserContextOptions { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index a68cb88abb779..be44a6c234984 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -39,10 +39,15 @@ function loadDummyServerCertsIfNeeded() { dummyServerTlsOptions = { key, cert }; } +type ALPNCacheOptions = { + socket?: stream.Duplex | undefined; + secureContext: tls.SecureContext | undefined; +}; + class ALPNCache { private _cache = new Map>(); - get(host: string, port: number, success: (protocol: string) => void) { + get(host: string, port: number, options: ALPNCacheOptions, success: (protocol: string) => void) { const cacheKey = `${host}:${port}`; { const result = this._cache.get(cacheKey); @@ -54,23 +59,56 @@ class ALPNCache { const result = new ManualPromise(); this._cache.set(cacheKey, result); result.then(success); - createTLSSocket({ - host, - port, - servername: net.isIP(host) ? undefined : host, - ALPNProtocols: ['h2', 'http/1.1'], - rejectUnauthorized: false, - }).then(socket => { - // The server may not respond with ALPN, in which case we default to http/1.1. - result.resolve(socket.alpnProtocol || 'http/1.1'); - socket.end(); - }).catch(error => { - debugLogger.log('client-certificates', `ALPN error: ${error.message}`); - result.resolve('http/1.1'); - }); + const fixtures = { + __testHookLookup: (options as any).__testHookLookup + }; + + if (!options.socket) { + createTLSSocket({ + host, + port, + servername: net.isIP(host) ? undefined : host, + ALPNProtocols: ['h2', 'http/1.1'], + rejectUnauthorized: false, + secureContext: options.secureContext, + ...fixtures, + }).then(socket => { + // The server may not respond with ALPN, in which case we default to http/1.1. + result.resolve(socket.alpnProtocol || 'http/1.1'); + socket.end(); + }).catch(error => { + debugLogger.log('client-certificates', `ALPN error: ${error.message}`); + result.resolve('http/1.1'); + }); + } else { + // a socket might be provided, for example, when using a proxy. + const socket = tls.connect({ + socket: options.socket, + port: port, + host: host, + ALPNProtocols: ['h2', 'http/1.1'], + rejectUnauthorized: false, + secureContext: options.secureContext, + servername: net.isIP(host) ? undefined : host + }); + socket.on('secureConnect', () => { + result.resolve(socket.alpnProtocol || 'http/1.1'); + socket.end(); + }); + socket.on('error', error => { + result.resolve('http/1.1'); + }); + socket.on('timeout', () => { + result.resolve('http/1.1'); + }); + } } } +// Only used for fixtures +type SocksProxyConnectionOptions = { +}; + class SocksProxyConnection { private readonly socksProxy: ClientCertificatesProxy; private readonly uid: string; @@ -84,12 +122,14 @@ class SocksProxyConnection { private _targetCloseEventListener: () => void; private _dummyServer: tls.Server | undefined; private _closed = false; + private _options: SocksProxyConnectionOptions; - constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { + constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number, options: SocksProxyConnectionOptions) { this.socksProxy = socksProxy; this.uid = uid; this.host = host; this.port = port; + this._options = options; this._targetCloseEventListener = () => { // Close the other end and cleanup TLS resources. this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }); @@ -99,10 +139,14 @@ class SocksProxyConnection { } async connect() { + const fixtures = { + __testHookLookup: (this._options as any).__testHookLookup + }; + if (this.socksProxy.proxyAgentFromOptions) this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false }); else - this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); + this.target = await createSocket({ host: rewriteToLocalhostIfNeeded(this.host), port: this.port, ...fixtures }); this.target.once('close', this._targetCloseEventListener); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); @@ -142,7 +186,7 @@ class SocksProxyConnection { this.target.write(data); } - private _attachTLSListeners() { + private async _attachTLSListeners() { this.internal = new stream.Duplex({ read: () => {}, write: (data, encoding, callback) => { @@ -150,7 +194,20 @@ class SocksProxyConnection { callback(); } }); - this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => { + const secureContext = this.socksProxy.secureContextForOrigin(new URL(`https://${this.host}:${this.port}`).origin); + const fixtures = { + __testHookLookup: (this._options as any).__testHookLookup + }; + + const alpnCacheOptions: ALPNCacheOptions = { + secureContext, + ...fixtures + }; + if (this.socksProxy.proxyAgentFromOptions) + alpnCacheOptions.socket = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false }); + + this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnCacheOptions, alpnProtocolChosenByServer => { + alpnCacheOptions.socket?.destroy(); debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`); if (this._closed) return; @@ -221,7 +278,7 @@ class SocksProxyConnection { rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], servername: !net.isIP(this.host) ? this.host : undefined, - secureContext: this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin), + secureContext: secureContext, }); targetTLS.once('secureConnect', () => { @@ -239,8 +296,9 @@ class SocksProxyConnection { export class ClientCertificatesProxy { _socksProxy: SocksProxy; private _connections: Map = new Map(); + private _patterns: Pattern[] = []; ignoreHTTPSErrors: boolean | undefined; - secureContextMap: Map = new Map(); + private _secureContextMap: Map = new Map(); alpnCache: ALPNCache; proxyAgentFromOptions: ReturnType | undefined; @@ -256,7 +314,9 @@ export class ClientCertificatesProxy { this._socksProxy.setPattern('*'); this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { try { - const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port); + const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port, { + __testHookLookup: (contextOptions as any).__testHookLookup + }); await connection.connect(); this._connections.set(payload.uid, connection); } catch (error) { @@ -277,7 +337,14 @@ export class ClientCertificatesProxy { // Step 1. Group certificates by origin. const origin2certs = new Map(); for (const cert of clientCertificates || []) { - const origin = normalizeOrigin(cert.origin); + const pattern = Pattern.fromString(cert.origin); + if (pattern === undefined) { + debugLogger.log('client-certificates', `Invalid client certificate pattern: ${cert.origin}`); + continue; + } else { + this._patterns.push(pattern); + } + const origin = pattern.normalizedOrigin; const certs = origin2certs.get(origin) || []; certs.push(cert); origin2certs.set(origin, certs); @@ -286,7 +353,7 @@ export class ClientCertificatesProxy { // Step 2. Create secure contexts for each origin. for (const [origin, certs] of origin2certs) { try { - this.secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs))); + this._secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs))); } catch (error) { error = rewriteOpenSSLErrorIfNeeded(error); throw rewriteErrorMessage(error, `Failed to load client certificate: ${error.message}`); @@ -294,6 +361,13 @@ export class ClientCertificatesProxy { } } + public secureContextForOrigin(origin: string): tls.SecureContext | undefined { + const pattern = this._patterns.find(p => p.matches(origin)); + if (!pattern) + return undefined; + return this._secureContextMap.get(pattern.normalizedOrigin); + } + public async listen() { const port = await this._socksProxy.listen(0, '127.0.0.1'); return { server: `socks5://127.0.0.1:${port}` }; @@ -304,14 +378,6 @@ export class ClientCertificatesProxy { } } -function normalizeOrigin(origin: string): string { - try { - return new URL(origin).origin; - } catch (error) { - return origin; - } -} - function convertClientCertificatesToTLSOptions( clientCertificates: types.BrowserContextOptions['clientCertificates'] ): Pick | undefined { @@ -338,7 +404,7 @@ export function getMatchingTLSOptionsForOrigin( origin: string ): Pick | undefined { const matchingCerts = clientCertificates?.filter(c => - normalizeOrigin(c.origin) === origin + Pattern.fromString(c.origin)?.matches(origin) ); return convertClientCertificatesToTLSOptions(matchingCerts); } @@ -357,3 +423,181 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', ].join('\n')); } + +/* + Pattern is a pattern that matches a URL. Based on the Chromium + implementation, used in content policies: + https://source.chromium.org/chromium/chromium/src/+/main:components/content_settings/core/common/content_settings_pattern.h;l=248;drc=20799f4c32d950ce93d495f44eec648400f38a19 + + Example: "https://[*.].hello.com/path" + + The only difference is that we don't support the precedence rules and + paths patterns are not implemented. +*/ +export class Pattern { + private readonly _scheme: string; + private readonly _isSchemeWildcard: boolean; + private readonly _host: string; + private readonly _isDomainWildcard: boolean; + private readonly _isSubdomainWildcard: boolean; + private readonly _port: string; + private readonly _isPortWildcard: boolean; + private readonly _host_parts: string[]; + private readonly _implicitPort: string; + private readonly _normalizedOrigin: string; + constructor(scheme: string, isSchemeWildcard: boolean, host: string, isDomainWildcard: boolean, isSubdomainWildcard: boolean, port: string, isPortWildcard: boolean) { + this._scheme = scheme; + this._isSchemeWildcard = isSchemeWildcard; + this._host = host; + this._isDomainWildcard = isDomainWildcard; + this._isSubdomainWildcard = isSubdomainWildcard; + this._port = port; + this._isPortWildcard = isPortWildcard; + this._host_parts = this._host.split('.').reverse(); + this._implicitPort = this._scheme === 'https' ? '443' : (this._scheme === 'http' ? '80' : ''); + this._normalizedOrigin = `${this._isSchemeWildcard ? '*' : this._scheme}://${this._isSubdomainWildcard ? '[*.]' : ''}${this._isDomainWildcard ? '*' : this._host}${this._isPortWildcard ? ':*' : this._port ? `:${this._port}` : ''}`; + } + + get scheme() { + return this._scheme; + } + + get host() { + return this._host; + } + + get port() { + return this._port; + } + + get isSchemeWildcard() { + return this._isSchemeWildcard; + } + + get isDomainWildcard() { + return this._isDomainWildcard; + } + + get isSubdomainWildcard() { + return this._isSubdomainWildcard; + } + + get isPortWildcard() { + return this._isPortWildcard; + } + + get normalizedOrigin() { + return this._normalizedOrigin; + } + + matches(url: string): boolean { + const urlObj = new URL(url); + const urlScheme = urlObj.protocol.replace(':', ''); + if (!this._isSchemeWildcard && this._scheme !== urlScheme) + return false; + + let urlPort = urlObj.port; + if (urlPort === '') + urlPort = urlScheme === 'https' ? '443' : (urlScheme === 'http' ? '80' : ''); + let patternPort = this._port; + if (patternPort === '') + patternPort = this._implicitPort; + + if (!this._isPortWildcard && patternPort !== urlPort) + return false; + + const urlHostParts = urlObj.hostname.split('.').reverse(); + + if (this._isDomainWildcard) + return true; + + if (this._host_parts.length > urlHostParts.length) + return false; + + for (let i = 0; i < this._host_parts.length; i++) { + if (this._host_parts[i] !== '*' && this._host_parts[i] !== urlHostParts[i]) + return false; + } + + if (this._host_parts.length < urlHostParts.length) + return this._isSubdomainWildcard; + + return true; + } + + static fromString(pattern: string, defaultScheme: string = 'https') { + + let restPattern = pattern; + let scheme = ''; + let host = ''; + let port = ''; + let isSchemeWildcard = false; + let isDomainWildcard = false; + let isSubdomainWildcard = false; + let isPortWildcard = false; + + const schemeIndex = pattern.indexOf('://'); + if (schemeIndex !== -1) { + scheme = restPattern.substring(0, schemeIndex); + restPattern = restPattern.substring(schemeIndex + 3); + } else { + scheme = defaultScheme; + } + // skip userinfo + const userInfoIndex = restPattern.indexOf('@'); + if (userInfoIndex !== -1) + restPattern = restPattern.substring(schemeIndex + 1); + + isSchemeWildcard = scheme === '*'; + isSubdomainWildcard = restPattern.startsWith('[*.]'); + if (isSubdomainWildcard) + restPattern = restPattern.substring(4); + + // literal ipv6 address + if (restPattern.startsWith('[')) { + const closingBracketIndex = restPattern.indexOf(']'); + if (closingBracketIndex === -1) + return undefined; + host = restPattern.substring(1, closingBracketIndex); + restPattern = restPattern.substring(closingBracketIndex + 1); + } else { + // ipv4 or domain + const slashIndex = restPattern.indexOf('/'); + const portIndex = restPattern.indexOf(':'); + host = restPattern; + if (slashIndex !== -1 && (portIndex === -1 || slashIndex < portIndex)) { + host = restPattern.substring(0, slashIndex); + restPattern = restPattern.substring(slashIndex); + } else if (portIndex !== -1) { + host = restPattern.substring(0, portIndex); + restPattern = restPattern.substring(portIndex); + } else { + restPattern = ''; + } + } + if (host === '*') + isDomainWildcard = true; + + const portIndex = restPattern.indexOf(':'); + if (portIndex !== -1) { + if (restPattern.startsWith(':*')) { + isPortWildcard = true; + port = '*'; + restPattern = restPattern.substring(2); + if (!restPattern.startsWith('/') || restPattern === '') + return undefined; + } else { + const slashIndex = restPattern.indexOf('/'); + if (slashIndex !== -1) { + port = restPattern.substring(1, slashIndex); + restPattern = restPattern.substring(slashIndex); + } else { + port = restPattern.substring(1); + restPattern = ''; + } + } + } + return new Pattern(scheme, isSchemeWildcard, host, isDomainWildcard, isSubdomainWildcard, port, isPortWildcard); + } + +} diff --git a/packages/playwright-core/src/server/utils/happyEyeballs.ts b/packages/playwright-core/src/server/utils/happyEyeballs.ts index eb8531db4c1a4..2815ad61de095 100644 --- a/packages/playwright-core/src/server/utils/happyEyeballs.ts +++ b/packages/playwright-core/src/server/utils/happyEyeballs.ts @@ -55,14 +55,14 @@ class HttpsHappyEyeballsAgent extends https.Agent { export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent({ keepAlive: true }); export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent({ keepAlive: true }); -export async function createSocket(host: string, port: number): Promise { +export async function createSocket(options: { host: string, port: number }): Promise { return new Promise((resolve, reject) => { - if (net.isIP(host)) { - const socket = net.createConnection({ host, port }); + if (net.isIP(options.host)) { + const socket = net.createConnection(options); socket.on('connect', () => resolve(socket)); socket.on('error', error => reject(error)); } else { - createConnectionAsync({ host, port }, (err, socket) => { + createConnectionAsync(options, (err, socket) => { if (err) reject(err); if (socket) diff --git a/packages/playwright-core/src/server/utils/socksProxy.ts b/packages/playwright-core/src/server/utils/socksProxy.ts index 40c62c43c3327..d6046b0d4fb6f 100644 --- a/packages/playwright-core/src/server/utils/socksProxy.ts +++ b/packages/playwright-core/src/server/utils/socksProxy.ts @@ -411,7 +411,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { private async _handleDirect(request: SocksSocketRequestedPayload) { try { - const socket = await createSocket(request.host, request.port); + const socket = await createSocket({ host: request.host, port: request.port }); socket.on('data', data => this._connections.get(request.uid)?.sendData(data)); socket.on('error', error => { this._connections.get(request.uid)?.error(error.message); @@ -540,7 +540,7 @@ export class SocksProxyHandler extends EventEmitter { try { if (this._redirectPortForTest) port = this._redirectPortForTest; - const socket = await createSocket(host, port); + const socket = await createSocket({ host, port }); socket.on('data', data => { const payload: SocksSocketDataPayload = { uid, data }; this.emit(SocksProxyHandler.Events.SocksData, payload); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 0dd5a1d68ac45..13a374d9ca5db 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9740,7 +9740,8 @@ export interface Browser { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -14786,7 +14787,8 @@ export interface BrowserType { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -17490,7 +17492,8 @@ export interface APIRequest { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; @@ -21994,7 +21997,8 @@ export interface BrowserContextOptions { */ clientCertificates?: Array<{ /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + * Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes + * `https` protocol, a hostname and optionally a port. */ origin: string; diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 42910134fcd74..4b9a8c2c8a0e2 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -129,16 +129,16 @@ export class TestProxy { } export async function setupSocksForwardingServer({ - port, forwardPort, allowedTargetPort + port, forwardPort, allowedTargetPort, additionalAllowedHosts = [] }: { - port: number, forwardPort: number, allowedTargetPort: number + port: number, forwardPort: number, allowedTargetPort: number, additionalAllowedHosts?: string[] }) { const connectHosts = []; const connections = new Map(); const socksProxy = new SocksProxy(); socksProxy.setPattern('*'); socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { - if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) { + if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost', ...additionalAllowedHosts].includes(payload.host) || payload.port !== allowedTargetPort) { socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); return; } diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 108dde2edc049..653bc2ede589c 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -24,6 +24,8 @@ import { expect, playwrightTest as base } from '../config/browserTest'; import type net from 'net'; import type { BrowserContextOptions } from 'packages/playwright-test'; import { setupSocksForwardingServer } from '../config/proxy'; +import { LookupAddress } from 'dns'; +import { Pattern } from '../../packages/playwright-core/lib/server/socksClientCertificatesInterceptor'; const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); type TestOptions = { @@ -172,6 +174,32 @@ test.describe('fetch', () => { await request.dispose(); }); + test('pass with trusted client certificates using pattern', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); + const __testHookLookup = (hostname: string): LookupAddress[] => { + if (hostname === 'www.hello.local') { + return [ + { address: '127.0.0.1', family: 4 }, + ]; + } + return []; + }; + + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: `https://[*.]hello.local:${new URL(serverURL).port}`, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + const response = await request.get(`https://www.hello.local:${new URL(serverURL).port}`, { __testHookLookup } as any); + expect(response.url()).toBe(`https://www.hello.local:${new URL(serverURL).port}/`); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => { const serverURL = await startCCServer(); proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); @@ -336,6 +364,33 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates when using pattern', async ({ browser, startCCServer, asset, browserName, isMac }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); + const __testHookLookup = (hostname: string): LookupAddress[] => { + if (hostname === 'www.hello.local') { + return [ + { address: '127.0.0.1', family: 4 }, + ]; + } + return []; + }; + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: `https://[*.]hello.local:${new URL(serverURL).port}`, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + ... + { __testHookLookup } as any + }); + const page = await context.newPage(); + const requestURL = `https://www.hello.local:${new URL(serverURL).port}`; + await page.goto(requestURL); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + test('should pass with matching certificates when passing as content', async ({ browser, startCCServer, asset, browserName, isMac }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); const page = await browser.newPage({ @@ -371,6 +426,50 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates and when a http proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, proxyServer, isMac }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const privateDomain = `private.playwright.test`; + proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); + + // make private domain resolve to unreachable server 192.0.2.0 + // any attempt to connect there will timeout + let interceptedHostnameLookup: string | undefined; + const __testHookLookup = (hostname: string): LookupAddress[] => { + if (hostname === privateDomain) { + interceptedHostnameLookup = hostname; + return [ + { address: '192.0.2.0', family: 4 }, + ]; + } + return []; + }; + + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain), + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: `localhost:${proxyServer.PORT}` }, + ... + { __testHookLookup } as any + }); + + const page = await context.newPage(); + const requestURL = serverURL.replace('127.0.0.1', privateDomain); + expect(proxyServer.connectHosts).toEqual([]); + await page.goto(requestURL); + + // only the proxy server should have tried to resolve the private domain + // and the test proxy server does not resolve domains + expect(interceptedHostnameLookup).toBe(undefined); + expect([...new Set(proxyServer.connectHosts)]).toEqual([`${privateDomain}:${serverPort}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName, isMac }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); const serverPort = parseInt(new URL(serverURL).port, 10); @@ -397,6 +496,55 @@ test.describe('browser', () => { await closeProxyServer(); }); + test('should pass with matching certificates and when a socks proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, isMac }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const privateDomain = `private.playwright.test`; + const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({ + port: test.info().workerIndex + 2048 + 2, + forwardPort: serverPort, + allowedTargetPort: serverPort, + additionalAllowedHosts: [privateDomain], + }); + + // make private domain resolve to unreachable server 192.0.2.0 + // any attempt to connect will timeout + let interceptedHostnameLookup: string | undefined; + const __testHookLookup = (hostname: string): LookupAddress[] => { + if (hostname === privateDomain) { + interceptedHostnameLookup = hostname; + return [ + { address: '192.0.2.0', family: 4 }, + ]; + } + return []; + }; + + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain), + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: proxyServerAddr }, + ... + { __testHookLookup } as any + }); + const page = await context.newPage(); + expect(connectHosts).toEqual([]); + const requestURL = serverURL.replace('127.0.0.1', privateDomain); + await page.goto(requestURL); + + // only the proxy server should have tried to resolve the private domain + // and the test proxy server does not resolve domains + expect(interceptedHostnameLookup).toBe(undefined); + expect(connectHosts).toEqual([`${privateDomain}:${serverPort}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + await closeProxyServer(); + }); + test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => { for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) { await test.step(`TLS version: ${tlsVersion}`, async () => { @@ -798,4 +946,81 @@ test.describe('browser', () => { await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); }); }); + + test.describe('patterns', () => { + test('should match patterns correctly', async () => { + const testCases = [ + { + pattern: 'https://*/path', + matches: [ + 'https://www.hello.com:443/path', + 'https://www.sub.hello.com/path', + 'https://10.0.0.1/path', + 'https://[::1]/path', + ], + nonMatches: [ + 'https://www.any.com:8443/path', + 'http://www.any.com:443/path', + ] + }, + { + pattern: 'https://*.*/path', + matches: [ + 'https://hello.com/path', + ], + nonMatches: [ + 'https://hello/path', + 'http://www.hello.com/path', + ] + }, + { + pattern: 'https://www.hello.com:443/path', + matches: [ + 'https://www.hello.com/path', + ], + nonMatches: [ + 'https://www.hello.com:8443/path', + 'http://www.hello.com:443/path', + ] + }, + { + pattern: 'https://[*.]*.hello.com/path', + matches: [ + 'https://www.foo.bar.hello.com/path', + ], + nonMatches: [ + 'https://hello.com/path', + 'http://hello.com/path', + ] + }, + { + pattern: 'https://*/path', + matches: [ + 'https://www.hello.com/path', + ], + nonMatches: [ + 'https://www.hello.com:8443/path', + 'http://www.hello.com/path', + ] + }, + { + pattern: '*/path', + matches: [ + 'https://www.hello.com/path', + ], + nonMatches: [ + 'http://www.hello.com/path', + ] + }, + ]; + for (const testCase of testCases) { + const pattern = Pattern.fromString(testCase.pattern); + expect(pattern).toBeTruthy(); + for (const url of testCase.matches) + expect(pattern.matches(url), `Expected pattern "${testCase.pattern}" to match URL "${url}"`).toBe(true); + for (const url of testCase.nonMatches) + expect(pattern.matches(url), `Expected pattern "${testCase.pattern}" to NOT match URL "${url}"`).toBe(false); + } + }); + }); });