diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index a68cb88abb..0a0f920832 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 }); @@ -142,7 +182,7 @@ class SocksProxyConnection { this.target.write(data); } - private _attachTLSListeners() { + private async _attachTLSListeners() { this.internal = new stream.Duplex({ read: () => {}, write: (data, encoding, callback) => { @@ -150,7 +190,20 @@ class SocksProxyConnection { callback(); } }); - this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => { + const secureContext = this.socksProxy.secureContextMap.get(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 +274,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', () => { @@ -256,7 +309,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) { diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 108dde2edc..4ad672d606 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -24,6 +24,7 @@ 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'; const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); type TestOptions = { @@ -371,6 +372,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 +442,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 () => {