From 2e8eb63cf8640243e4cb18975ceca215ac038221 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 2 Jun 2026 15:57:45 -0700 Subject: [PATCH 1/4] fix(client-certificates): do not intercept TLS for non-matching origins The SOCKS MITM proxy terminated and re-established TLS for every HTTPS connection, even for origins without a configured client certificate. This unnecessarily intercepted third-party traffic, hurting performance and turning transient upstream TLS failures into spurious errors. Now the handshake is only intercepted when the destination origin has a matching client certificate; otherwise the connection is passed through untouched and the browser negotiates TLS directly with the server. Fixes: https://github.com/microsoft/playwright/issues/41106 --- .../socksClientCertificatesInterceptor.ts | 16 +++++++------ tests/library/client-certificates.spec.ts | 23 +++++++++++++++++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 3a5311aa84ab0..9f383edeb6611 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -160,9 +160,14 @@ class SocksProxyConnection { // the protocol on the first package and attach appropriate listeners. if (!this._firstPackageReceived) { this._firstPackageReceived = true; + const secureContext = this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)); // 0x16 is SSLv3/TLS "handshake" content type: https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record - if (data[0] === 0x16) - this._establishTlsTunnel(this._browserEncrypted, data); + // Only intercept the TLS handshake to inject a client certificate when the destination origin + // actually has one configured. Otherwise we pass the connection through untouched, so the browser + // negotiates TLS directly with the server. This avoids unnecessarily terminating and re-establishing + // TLS for third-party origins, which is both slower and a source of spurious errors. + if (data[0] === 0x16 && secureContext) + this._establishTlsTunnel(this._browserEncrypted, data, secureContext); else this._establishPlaintextTunnel(this._browserEncrypted); } @@ -176,14 +181,11 @@ class SocksProxyConnection { this._serverEncrypted.pipe(browserEncrypted); } - private _establishTlsTunnel(browserEncrypted: stream.Duplex, clientHello: Buffer) { + private _establishTlsTunnel(browserEncrypted: stream.Duplex, clientHello: Buffer, secureContext: tls.SecureContext) { const browserALPNProtocols = parseALPNFromClientHello(clientHello) || ['http/1.1']; debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} offers ALPN ${browserALPNProtocols}`); - const secureContext = this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)); - // Without a matching client certificate the proxy is a transparent pass-through, so let the - // browser validate the server cert instead of failing here on e.g. self-signed certs. - const rejectUnauthorized = !!secureContext && !this.socksProxy.ignoreHTTPSErrors; + const rejectUnauthorized = !this.socksProxy.ignoreHTTPSErrors; const serverDecrypted = tls.connect({ socket: this._serverEncrypted, diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index bd2c603025f11..90c9253052538 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -343,6 +343,24 @@ test.describe('browser', () => { await page.close(); }); + test('should not intercept TLS for origins without a client certificate', async ({ browser, asset, httpsServer }) => { + // Origins without a matching client certificate must not be TLS-terminated by the proxy. + // If they were, the browser would observe the proxy's self-signed certificate (CN=localhost) + // instead of the real server certificate (CN=playwright-test). + // https://github.com/microsoft/playwright/issues/41106 + const page = await browser.newPage({ + clientCertificates: [{ + origin: 'https://not-matching.com', + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + const response = await page.goto(httpsServer.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect((await response.securityDetails()).subjectName).toBe('playwright-test'); + await page.close(); + }); + test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName, isMac }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); const page = await browser.newPage({ @@ -636,8 +654,9 @@ test.describe('browser', () => { await new Promise(resolve => server.listen(0, 'localhost', resolve)); const port = (server.address() as net.AddressInfo).port; - const origin = 'https://' + (browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost'); - const serverUrl = `${origin}:${port}`; + const host = browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost'; + const serverUrl = `https://${host}:${port}`; + const origin = new URL(serverUrl).origin; const context = await browser.newContext({ ignoreHTTPSErrors: true, From d87dbf95e8f0500b9cf24fe0958256f9eb23361d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 2 Jun 2026 16:49:40 -0700 Subject: [PATCH 2/4] chore(client-certificates): simplify interceptor and test - Look up the secure context only for actual TLS handshakes instead of on every connection. - Drop a redundant URL round-trip in the renegotiation test. --- .../src/server/socksClientCertificatesInterceptor.ts | 4 ++-- tests/library/client-certificates.spec.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 9f383edeb6611..a9c8cb87b3571 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -160,13 +160,13 @@ class SocksProxyConnection { // the protocol on the first package and attach appropriate listeners. if (!this._firstPackageReceived) { this._firstPackageReceived = true; - const secureContext = this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)); // 0x16 is SSLv3/TLS "handshake" content type: https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record // Only intercept the TLS handshake to inject a client certificate when the destination origin // actually has one configured. Otherwise we pass the connection through untouched, so the browser // negotiates TLS directly with the server. This avoids unnecessarily terminating and re-establishing // TLS for third-party origins, which is both slower and a source of spurious errors. - if (data[0] === 0x16 && secureContext) + const secureContext = data[0] === 0x16 ? this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)) : undefined; + if (secureContext) this._establishTlsTunnel(this._browserEncrypted, data, secureContext); else this._establishPlaintextTunnel(this._browserEncrypted); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 90c9253052538..c9dfe696e16fa 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -656,12 +656,11 @@ test.describe('browser', () => { const port = (server.address() as net.AddressInfo).port; const host = browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost'; const serverUrl = `https://${host}:${port}`; - const origin = new URL(serverUrl).origin; const context = await browser.newContext({ ignoreHTTPSErrors: true, clientCertificates: [{ - origin, + origin: serverUrl, certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'), }], From cb06c260bba8bd21cd2c5e3a526efe2824700568 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 2 Jun 2026 16:53:07 -0700 Subject: [PATCH 3/4] chore(client-certificates): trim comments --- .../src/server/socksClientCertificatesInterceptor.ts | 7 ++----- tests/library/client-certificates.spec.ts | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index a9c8cb87b3571..3174827e28bd4 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -160,11 +160,8 @@ class SocksProxyConnection { // the protocol on the first package and attach appropriate listeners. if (!this._firstPackageReceived) { this._firstPackageReceived = true; - // 0x16 is SSLv3/TLS "handshake" content type: https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record - // Only intercept the TLS handshake to inject a client certificate when the destination origin - // actually has one configured. Otherwise we pass the connection through untouched, so the browser - // negotiates TLS directly with the server. This avoids unnecessarily terminating and re-establishing - // TLS for third-party origins, which is both slower and a source of spurious errors. + // 0x16 is the TLS "handshake" content type. Only intercept it when the origin has a client + // certificate; otherwise pass the connection through so the browser talks TLS to the server directly. const secureContext = data[0] === 0x16 ? this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)) : undefined; if (secureContext) this._establishTlsTunnel(this._browserEncrypted, data, secureContext); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index c9dfe696e16fa..0dc11f708a17e 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -344,10 +344,9 @@ test.describe('browser', () => { }); test('should not intercept TLS for origins without a client certificate', async ({ browser, asset, httpsServer }) => { - // Origins without a matching client certificate must not be TLS-terminated by the proxy. - // If they were, the browser would observe the proxy's self-signed certificate (CN=localhost) - // instead of the real server certificate (CN=playwright-test). // https://github.com/microsoft/playwright/issues/41106 + // If the proxy intercepted this origin, the browser would see its self-signed cert (CN=localhost) + // instead of the real server cert (CN=playwright-test). const page = await browser.newPage({ clientCertificates: [{ origin: 'https://not-matching.com', From 3b9871e6780e19cab86811e5d753c16a8234a2a7 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 2 Jun 2026 16:54:53 -0700 Subject: [PATCH 4/4] chore(client-certificates): use issue annotation in test --- tests/library/client-certificates.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 0dc11f708a17e..4a083a529e679 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -343,8 +343,9 @@ test.describe('browser', () => { await page.close(); }); - test('should not intercept TLS for origins without a client certificate', async ({ browser, asset, httpsServer }) => { - // https://github.com/microsoft/playwright/issues/41106 + test('should not intercept TLS for origins without a client certificate', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41106' }, + }, async ({ browser, asset, httpsServer }) => { // If the proxy intercepted this origin, the browser would see its self-signed cert (CN=localhost) // instead of the real server cert (CN=playwright-test). const page = await browser.newPage({