diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2c4268524b..f16a60fe00 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,17 +81,19 @@ 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; + private _targetCloseEventListener: () => void; constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { this.socksProxy = socksProxy; this.uid = uid; this.host = host; this.port = port; + this._targetCloseEventListener = () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }); } 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', this._targetCloseEventListener); this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.socksProxy._socksProxy.socketConnected({ uid: this.uid, @@ -139,7 +142,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 +156,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 +173,30 @@ 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 20+ + if ('performServerHandshake' in http2) { + // 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. + this.target.removeListener('close', this._targetCloseEventListener); + // @ts-expect-error + const session: http2.ServerHttp2Session = 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(); + }); + stream.on('error', () => 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 +204,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..9f055a678b 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 }[] = []; @@ -279,7 +280,8 @@ test.describe('browser', () => { test('support http2', async ({ browser, startCCServer, asset, browserName }) => { test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost'); - const serverURL = await startCCServer({ http2: true }); + const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux'; + const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 }); const page = await browser.newPage({ clientCertificates: [{ origin: new URL(serverURL).origin, @@ -289,7 +291,7 @@ test.describe('browser', () => { }); // TODO: We should investigate why http2 is not supported in WebKit on Linux. // https://bugs.webkit.org/show_bug.cgi?id=276990 - const expectedProtocol = browserName === 'webkit' && process.platform === 'linux' ? 'http/1.1' : 'h2'; + const expectedProtocol = enableHTTP1FallbackWhenUsingHttp2 ? 'http/1.1' : 'h2'; { await page.goto(serverURL.replace('localhost', 'local.playwright')); await expect(page.getByTestId('message')).toHaveText('Sorry, but you need to provide a client certificate to continue.'); @@ -306,7 +308,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 +330,25 @@ 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'); + test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); + + 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();