From c5b7ce86dc1de0833f344557f7bb1d2dd9a41227 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 24 Jul 2024 11:39:39 +0200 Subject: [PATCH] feat(client-certificates): add http2 support (#31786) --- .../socksClientCertificatesInterceptor.ts | 142 ++++++++++++------ .../playwright-core/src/utils/debugLogger.ts | 1 + tests/library/client-certificates.spec.ts | 67 ++++++++- 3 files changed, 159 insertions(+), 51 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 9591927c0a..87b0f11c16 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -21,10 +21,46 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket } from '../utils/happy-eyeballs'; -import { globToRegex, isUnderTest } from '../utils'; +import { globToRegex, isUnderTest, ManualPromise } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; +import { debugLogger } from '../utils/debugLogger'; + +class ALPNCache { + private _cache = new Map>(); + + get(host: string, port: number, success: (protocol: string) => void) { + const cacheKey = `${host}:${port}`; + { + const result = this._cache.get(cacheKey); + if (result) { + result.then(success); + return; + } + } + const result = new ManualPromise(); + this._cache.set(cacheKey, result); + result.then(success); + const socket = tls.connect({ + host, + port, + servername: net.isIP(host) ? undefined : host, + ALPNProtocols: ['h2', 'http/1.1'], + rejectUnauthorized: false, + }); + socket.on('secureConnect', () => { + // 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(); + }); + socket.on('error', error => { + debugLogger.log('client-certificates', `ALPN error: ${error.message}`); + result.resolve('http/1.1'); + socket.end(); + }); + } +} class SocksProxyConnection { private readonly socksProxy: ClientCertificatesProxy; @@ -44,7 +80,7 @@ class SocksProxyConnection { } async connect() { - this.target = await createSocket(this.host === 'local.playwright' ? 'localhost' : this.host, this.port); + this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); this.target.on('close', () => 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({ @@ -84,50 +120,62 @@ class SocksProxyConnection { callback(); } }); - const dummyServer = tls.createServer({ - key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), - cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), - }); - dummyServer.emit('connection', this.internal); - dummyServer.on('secureConnection', internalTLS => { - internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); + this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => { + debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`); + const dummyServer = tls.createServer({ + key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), + cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), + ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'], + }); + this.internal?.on('close', () => dummyServer.close()); + 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, + port: this.port, + rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, + ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], + ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), + }; + if (!net.isIP(this.host)) + tlsOptions.servername = this.host; + if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) + tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; + const targetTLS = tls.connect(tlsOptions); - const tlsOptions: tls.ConnectionOptions = { - socket: this.target, - host: this.host, - port: this.port, - rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), - }; - if (!net.isIP(this.host)) - tlsOptions.servername = this.host; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; - const targetTLS = tls.connect(tlsOptions); + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); + // Handle close and errors + const closeBothSockets = () => { + internalTLS.end(); + targetTLS.end(); + }; - // Handle close and errors - const closeBothSockets = () => { - internalTLS.end(); - targetTLS.end(); - }; + internalTLS.on('end', () => closeBothSockets()); + targetTLS.on('end', () => closeBothSockets()); - internalTLS.on('end', () => closeBothSockets()); - targetTLS.on('end', () => closeBothSockets()); - - internalTLS.on('error', () => closeBothSockets()); - targetTLS.on('error', error => { - const responseBody = 'Playwright client-certificate error: ' + error.message; - internalTLS.end([ - 'HTTP/1.1 503 Internal Server Error', - 'Content-Type: text/html; charset=utf-8', - 'Content-Length: ' + Buffer.byteLength(responseBody), - '\r\n', - responseBody, - ].join('\r\n')); - closeBothSockets(); + internalTLS.on('error', () => closeBothSockets()); + targetTLS.on('error', error => { + debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); + if (internalTLS?.alpnProtocol === 'h2') { + // https://github.com/nodejs/node/issues/46152 + // TODO: http2.performServerHandshake does not work here for some reason. + } 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', + 'Content-Length: ' + Buffer.byteLength(responseBody), + '\r\n', + responseBody, + ].join('\r\n')); + } + closeBothSockets(); + }); }); }); } @@ -138,10 +186,12 @@ export class ClientCertificatesProxy { private _connections: Map = new Map(); ignoreHTTPSErrors: boolean | undefined; clientCertificates: channels.BrowserNewContextOptions['clientCertificates']; + alpnCache: ALPNCache; constructor( contextOptions: Pick ) { + this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.clientCertificates = contextOptions.clientCertificates; this._socksProxy = new SocksProxy(); @@ -178,7 +228,7 @@ const kClientCertificatesGlobRegex = Symbol('kClientCertificatesGlobRegex'); export function clientCertificatesToTLSOptions( clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], - requestURL: string + origin: string ): Pick | undefined { const matchingCerts = clientCertificates?.filter(c => { let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex]; @@ -187,7 +237,7 @@ export function clientCertificatesToTLSOptions( (c as any)[kClientCertificatesGlobRegex] = regex; } regex.lastIndex = 0; - return regex.test(requestURL); + return regex.test(origin); }); if (!matchingCerts || !matchingCerts.length) return; @@ -206,3 +256,7 @@ export function clientCertificatesToTLSOptions( } return tlsOptions; } + +function rewriteToLocalhostIfNeeded(host: string): string { + return host === 'local.playwright' ? 'localhost' : host; +} diff --git a/packages/playwright-core/src/utils/debugLogger.ts b/packages/playwright-core/src/utils/debugLogger.ts index bda373e0ef..a5196da896 100644 --- a/packages/playwright-core/src/utils/debugLogger.ts +++ b/packages/playwright-core/src/utils/debugLogger.ts @@ -24,6 +24,7 @@ const debugLoggerColorMap = { 'download': 34, // green 'browser': 0, // reset 'socks': 92, // purple + 'client-certificates': 92, // purple 'error': 160, // red, 'channel': 33, // blue 'server': 45, // cyan diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 712929b96b..d1b15e4658 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -42,27 +42,30 @@ const test = base.extend({ ], requestCert: true, rejectUnauthorized: false, + allowHTTP1: true, }, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => { const tlsSocket = req.socket as import('tls').TLSSocket; // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336 expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true); + const prefix = `ALPN protocol: ${tlsSocket.alpnProtocol}\n`; const cert = tlsSocket.getPeerCertificate(); if (tlsSocket.authorized) { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); + res.end(prefix + `Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); } else if (cert.subject) { res.writeHead(403, { 'Content-Type': 'text/html' }); - res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); + res.end(prefix + `Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); } else { res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(`Sorry, but you need to provide a client certificate to continue.`); + res.end(prefix + `Sorry, but you need to provide a client certificate to continue.`); } }); await new Promise(f => server.listen(0, 'localhost', () => f())); const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost'; return `https://${host}:${(server.address() as net.AddressInfo).port}/`; }); - await new Promise(resolve => server.close(() => resolve())); + if (server) + await new Promise(resolve => server.close(() => resolve())); }, }); @@ -110,7 +113,7 @@ test.describe('fetch', () => { const request = await playwright.request.newContext(); const response = await request.get(serverURL); expect(response.status()).toBe(401); - expect(await response.text()).toBe('Sorry, but you need to provide a client certificate to continue.'); + expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.'); await request.dispose(); }); @@ -141,7 +144,7 @@ test.describe('fetch', () => { const response = await request.get(serverURL); expect(response.url()).toBe(serverURL); expect(response.status()).toBe(403); - expect(await response.text()).toBe('Sorry Bob, certificates from Bob are not welcome here.'); + expect(await response.text()).toContain('Sorry Bob, certificates from Bob are not welcome here.'); await request.dispose(); }); @@ -157,7 +160,7 @@ test.describe('fetch', () => { const response = await request.get(serverURL); expect(response.url()).toBe(serverURL); expect(response.status()).toBe(200); - expect(await response.text()).toBe('Hello Alice, your certificate was issued by localhost!'); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); await request.dispose(); }); @@ -258,6 +261,56 @@ test.describe('browser', () => { await page.close(); }); + 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 page = await browser.newPage({ + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + // 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'; + { + await page.goto(serverURL.replace('localhost', 'local.playwright')); + await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); + await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible(); + } + { + await page.goto(serverURL); + await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); + await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible(); + } + await page.close(); + }); + + 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 browser = await browserType.launch({ args: ['--disable-http2'] }); + const page = await browser.newPage({ + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + { + await page.goto(serverURL.replace('localhost', 'local.playwright')); + await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); + await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible(); + } + { + await page.goto(serverURL); + await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); + await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible(); + } + await browser.close(); + }); + test.describe('persistentContext', () => { test('validate input', async ({ launchPersistent }) => { test.slow();