diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 6367e8875e..a4d9cd9ee2 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -40,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; import { kMaxCookieExpiresDateInSeconds } from './network'; -import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; +import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { userAgent: string; @@ -195,7 +195,7 @@ export abstract class APIRequestContext extends SdkObject { maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects, timeout, deadline, - ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin), + ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; // rejectUnauthorized = undefined is treated as true in Node.js 12. @@ -365,7 +365,7 @@ export abstract class APIRequestContext extends SdkObject { maxRedirects: options.maxRedirects - 1, timeout: options.timeout, deadline: options.deadline, - ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.origin), + ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, url.origin), __testHookLookup: options.__testHookLookup, }; // rejectUnauthorized = undefined is treated as true in node 12. diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 5510303882..32a7b1cebd 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -157,7 +157,6 @@ class SocksProxyConnection { let targetTLS: tls.TLSSocket | undefined = undefined; const handleError = (error: Error) => { - error = rewriteOpenSSLErrorIfNeeded(error); debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`); const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message) .replaceAll('\n', '
'); @@ -198,14 +197,6 @@ class SocksProxyConnection { } }; - let secureContext: tls.SecureContext; - try { - secureContext = tls.createSecureContext(clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, new URL(`https://${this.host}:${this.port}`).origin)); - } catch (error) { - handleError(error); - return; - } - if (this._closed) { internalTLS.destroy(); return; @@ -217,7 +208,7 @@ class SocksProxyConnection { rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], servername: !net.isIP(this.host) ? this.host : undefined, - secureContext, + secureContext: this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin), }); targetTLS.once('secureConnect', () => { @@ -236,7 +227,7 @@ export class ClientCertificatesProxy { _socksProxy: SocksProxy; private _connections: Map = new Map(); ignoreHTTPSErrors: boolean | undefined; - clientCertificates: channels.BrowserNewContextOptions['clientCertificates']; + secureContextMap: Map = new Map(); alpnCache: ALPNCache; constructor( @@ -244,7 +235,7 @@ export class ClientCertificatesProxy { ) { this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; - this.clientCertificates = contextOptions.clientCertificates; + this._initSecureContexts(contextOptions.clientCertificates); this._socksProxy = new SocksProxy(); this._socksProxy.setPattern('*'); this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { @@ -266,6 +257,27 @@ export class ClientCertificatesProxy { loadDummyServerCertsIfNeeded(); } + _initSecureContexts(clientCertificates: channels.BrowserNewContextOptions['clientCertificates']) { + // Step 1. Group certificates by origin. + const origin2certs = new Map(); + for (const cert of clientCertificates || []) { + const origin = normalizeOrigin(cert.origin); + const certs = origin2certs.get(origin) || []; + certs.push(cert); + origin2certs.set(origin, certs); + } + + // Step 2. Create secure contexts for each origin. + for (const [origin, certs] of origin2certs) { + try { + this.secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs))); + } catch (error) { + error = rewriteOpenSSLErrorIfNeeded(error); + throw rewriteErrorMessage(error, `Failed to load client certificate: ${error.message}`); + } + } + } + public async listen(): Promise { const port = await this._socksProxy.listen(0, '127.0.0.1'); return `socks5://127.0.0.1:${port}`; @@ -276,25 +288,25 @@ export class ClientCertificatesProxy { } } -export function clientCertificatesToTLSOptions( - clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], - origin: string +function normalizeOrigin(origin: string): string { + try { + return new URL(origin).origin; + } catch (error) { + return origin; + } +} + +function convertClientCertificatesToTLSOptions( + clientCertificates: channels.BrowserNewContextOptions['clientCertificates'] ): Pick | undefined { - const matchingCerts = clientCertificates?.filter(c => { - try { - return new URL(c.origin).origin === origin; - } catch (error) { - return c.origin === origin; - } - }); - if (!matchingCerts || !matchingCerts.length) + if (!clientCertificates || !clientCertificates.length) return; const tlsOptions = { pfx: [] as { buf: Buffer, passphrase?: string }[], key: [] as { pem: Buffer, passphrase?: string }[], cert: [] as Buffer[], }; - for (const cert of matchingCerts) { + for (const cert of clientCertificates) { if (cert.cert) tlsOptions.cert.push(cert.cert); if (cert.key) @@ -305,6 +317,16 @@ export function clientCertificatesToTLSOptions( return tlsOptions; } +export function getMatchingTLSOptionsForOrigin( + clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], + origin: string +): Pick | undefined { + const matchingCerts = clientCertificates?.filter(c => + normalizeOrigin(c.origin) === origin + ); + return convertClientCertificatesToTLSOptions(matchingCerts); +} + function rewriteToLocalhostIfNeeded(host: string): string { return host === 'local.playwright' ? 'localhost' : host; } diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 11508e1081..fa7eb8bbbc 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -362,32 +362,26 @@ test.describe('browser', () => { test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); - const page = await browser.newPage({ + await expect(browser.newPage({ ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), passphrase: 'secure' }], - }); - await page.goto(serverURL); - await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible(); - await page.close(); + })).rejects.toThrow('Unsupported TLS certificate'); }); test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); - const page = await browser.newPage({ + await expect(browser.newPage({ ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, pfxPath: asset('client-certificates/client/trusted/cert.pfx'), passphrase: 'this-password-is-incorrect' }], - }); - await page.goto(serverURL); - await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible(); - await page.close(); + })).rejects.toThrow('Failed to load client certificate: mac verify failure'); }); test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {