chore: validate client-certificates on context creation (#32168)
This commit is contained in:
parent
570e05699e
commit
faf4853259
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue