chore: validate client-certificates on context creation (#32168)

This commit is contained in:
Max Schmitt 2024-08-19 09:02:14 +02:00 committed by GitHub
parent 570e05699e
commit faf4853259
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 53 additions and 37 deletions

View file

@ -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.

View file

@ -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', ' <br>');
@ -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<string, SocksProxyConnection> = new Map();
ignoreHTTPSErrors: boolean | undefined;
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'];
secureContextMap: Map<string, tls.SecureContext> = 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<string, channels.BrowserNewContextOptions['clientCertificates']>();
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<string> {
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
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c => {
function normalizeOrigin(origin: string): string {
try {
return new URL(c.origin).origin === origin;
return new URL(origin).origin;
} catch (error) {
return c.origin === origin;
return origin;
}
});
if (!matchingCerts || !matchingCerts.length)
}
function convertClientCertificatesToTLSOptions(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates']
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
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<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c =>
normalizeOrigin(c.origin) === origin
);
return convertClientCertificatesToTLSOptions(matchingCerts);
}
function rewriteToLocalhostIfNeeded(host: string): string {
return host === 'local.playwright' ? 'localhost' : host;
}

View file

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