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 }) => {