diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2c4268524b..cff427d72c 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -16,6 +16,7 @@ import net from 'net'; import path from 'path'; +import http2 from 'http2'; import type https from 'https'; import fs from 'fs'; import tls from 'tls'; @@ -80,6 +81,7 @@ class SocksProxyConnection { target!: net.Socket; // In case of http, we just pipe data to the target socket and they are |undefined|. internal: stream.Duplex | undefined; + _interceptClose = false; constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { this.socksProxy = socksProxy; @@ -90,7 +92,12 @@ class SocksProxyConnection { async connect() { this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); - this.target.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); + this.target.on('close', () => { + // In case of an 'error' event on the target connection, we still need to perform the http2 handshake on the browser side. + // This is an async operation, so we need to intercept the close event to prevent the socket from being closed too early. + if (!this._interceptClose) + this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }); + }); this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.socksProxy._socksProxy.socketConnected({ uid: this.uid, @@ -139,7 +146,6 @@ class SocksProxyConnection { dummyServer.emit('connection', this.internal); dummyServer.on('secureConnection', internalTLS => { debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); - internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); const tlsOptions: tls.ConnectionOptions = { socket: this.target, host: this.host, @@ -154,8 +160,10 @@ class SocksProxyConnection { tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; const targetTLS = tls.connect(tlsOptions); - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); + targetTLS.on('secureConnect', () => { + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); + }); // Handle close and errors const closeBothSockets = () => { @@ -169,11 +177,27 @@ class SocksProxyConnection { internalTLS.on('error', () => closeBothSockets()); targetTLS.on('error', error => { debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); + const responseBody = 'Playwright client-certificate error: ' + error.message; if (internalTLS?.alpnProtocol === 'h2') { - // https://github.com/nodejs/node/issues/46152 - // TODO: http2.performServerHandshake does not work here for some reason. + // This method is available only in Node.js 18+ + if ('performServerHandshake' in http2) { + this._interceptClose = true; + // @ts-expect-error + const session = http2.performServerHandshake(internalTLS); + session.on('stream', (stream: http2.ServerHttp2Stream) => { + stream.respond({ + 'content-type': 'text/html', + [http2.constants.HTTP2_HEADER_STATUS]: 503, + }); + stream.end(responseBody, () => { + session.close(); + closeBothSockets(); + }); + }); + } else { + closeBothSockets(); + } } else { - const responseBody = 'Playwright client-certificate error: ' + error.message; internalTLS.end([ 'HTTP/1.1 503 Internal Server Error', 'Content-Type: text/html; charset=utf-8', @@ -181,8 +205,8 @@ class SocksProxyConnection { '\r\n', responseBody, ].join('\r\n')); + closeBothSockets(); } - closeBothSockets(); }); }); }); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index ff60010d5b..2c54820704 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -25,6 +25,7 @@ const { createHttpsServer, createHttp2Server } = require('../../packages/playwri type TestOptions = { startCCServer(options?: { http2?: boolean; + enableHTTP1FallbackWhenUsingHttp2?: boolean; useFakeLocalhost?: boolean; }): Promise, }; @@ -42,7 +43,7 @@ const test = base.extend({ ], requestCert: true, rejectUnauthorized: false, - allowHTTP1: true, + allowHTTP1: options?.enableHTTP1FallbackWhenUsingHttp2, }, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => { const tlsSocket = req.socket as import('tls').TLSSocket; const parts: { key: string, value: any }[] = []; @@ -306,7 +307,7 @@ test.describe('browser', () => { test('support http2 if the browser only supports http1.1', async ({ browserType, browserName, startCCServer, asset }) => { test.skip(browserName !== 'chromium'); - const serverURL = await startCCServer({ http2: true }); + const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true }); const browser = await browserType.launch({ args: ['--disable-http2'] }); const page = await browser.newPage({ clientCertificates: [{ @@ -328,6 +329,23 @@ test.describe('browser', () => { await browser.close(); }); + test('should return target connection errors when using http2', async ({ browser, startCCServer, asset, browserName }) => { + test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost'); + test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990'); + process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html'); + const serverURL = await startCCServer({ http2: true }); + const page = await browser.newPage({ + clientCertificates: [{ + origin: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com', + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible(); + await page.close(); + }); + test.describe('persistentContext', () => { test('validate input', async ({ launchPersistent }) => { test.slow();