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 * as types from './types';
|
||||||
import type { HeadersArray, ProxySettings } from './types';
|
import type { HeadersArray, ProxySettings } from './types';
|
||||||
import { kMaxCookieExpiresDateInSeconds } from './network';
|
import { kMaxCookieExpiresDateInSeconds } from './network';
|
||||||
import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
|
import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
|
||||||
|
|
||||||
type FetchRequestOptions = {
|
type FetchRequestOptions = {
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
|
|
@ -195,7 +195,7 @@ export abstract class APIRequestContext extends SdkObject {
|
||||||
maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects,
|
maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects,
|
||||||
timeout,
|
timeout,
|
||||||
deadline,
|
deadline,
|
||||||
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin),
|
...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, requestUrl.origin),
|
||||||
__testHookLookup: (params as any).__testHookLookup,
|
__testHookLookup: (params as any).__testHookLookup,
|
||||||
};
|
};
|
||||||
// rejectUnauthorized = undefined is treated as true in Node.js 12.
|
// rejectUnauthorized = undefined is treated as true in Node.js 12.
|
||||||
|
|
@ -365,7 +365,7 @@ export abstract class APIRequestContext extends SdkObject {
|
||||||
maxRedirects: options.maxRedirects - 1,
|
maxRedirects: options.maxRedirects - 1,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
deadline: options.deadline,
|
deadline: options.deadline,
|
||||||
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.origin),
|
...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, url.origin),
|
||||||
__testHookLookup: options.__testHookLookup,
|
__testHookLookup: options.__testHookLookup,
|
||||||
};
|
};
|
||||||
// rejectUnauthorized = undefined is treated as true in node 12.
|
// rejectUnauthorized = undefined is treated as true in node 12.
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,6 @@ class SocksProxyConnection {
|
||||||
let targetTLS: tls.TLSSocket | undefined = undefined;
|
let targetTLS: tls.TLSSocket | undefined = undefined;
|
||||||
|
|
||||||
const handleError = (error: Error) => {
|
const handleError = (error: Error) => {
|
||||||
error = rewriteOpenSSLErrorIfNeeded(error);
|
|
||||||
debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`);
|
debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`);
|
||||||
const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message)
|
const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message)
|
||||||
.replaceAll('\n', ' <br>');
|
.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) {
|
if (this._closed) {
|
||||||
internalTLS.destroy();
|
internalTLS.destroy();
|
||||||
return;
|
return;
|
||||||
|
|
@ -217,7 +208,7 @@ class SocksProxyConnection {
|
||||||
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
|
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
|
||||||
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
|
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
|
||||||
servername: !net.isIP(this.host) ? this.host : undefined,
|
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', () => {
|
targetTLS.once('secureConnect', () => {
|
||||||
|
|
@ -236,7 +227,7 @@ export class ClientCertificatesProxy {
|
||||||
_socksProxy: SocksProxy;
|
_socksProxy: SocksProxy;
|
||||||
private _connections: Map<string, SocksProxyConnection> = new Map();
|
private _connections: Map<string, SocksProxyConnection> = new Map();
|
||||||
ignoreHTTPSErrors: boolean | undefined;
|
ignoreHTTPSErrors: boolean | undefined;
|
||||||
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'];
|
secureContextMap: Map<string, tls.SecureContext> = new Map();
|
||||||
alpnCache: ALPNCache;
|
alpnCache: ALPNCache;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -244,7 +235,7 @@ export class ClientCertificatesProxy {
|
||||||
) {
|
) {
|
||||||
this.alpnCache = new ALPNCache();
|
this.alpnCache = new ALPNCache();
|
||||||
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
|
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
|
||||||
this.clientCertificates = contextOptions.clientCertificates;
|
this._initSecureContexts(contextOptions.clientCertificates);
|
||||||
this._socksProxy = new SocksProxy();
|
this._socksProxy = new SocksProxy();
|
||||||
this._socksProxy.setPattern('*');
|
this._socksProxy.setPattern('*');
|
||||||
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
|
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
|
||||||
|
|
@ -266,6 +257,27 @@ export class ClientCertificatesProxy {
|
||||||
loadDummyServerCertsIfNeeded();
|
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> {
|
public async listen(): Promise<string> {
|
||||||
const port = await this._socksProxy.listen(0, '127.0.0.1');
|
const port = await this._socksProxy.listen(0, '127.0.0.1');
|
||||||
return `socks5://127.0.0.1:${port}`;
|
return `socks5://127.0.0.1:${port}`;
|
||||||
|
|
@ -276,25 +288,25 @@ export class ClientCertificatesProxy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clientCertificatesToTLSOptions(
|
function normalizeOrigin(origin: string): string {
|
||||||
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'],
|
|
||||||
origin: string
|
|
||||||
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
|
|
||||||
const matchingCerts = clientCertificates?.filter(c => {
|
|
||||||
try {
|
try {
|
||||||
return new URL(c.origin).origin === origin;
|
return new URL(origin).origin;
|
||||||
} catch (error) {
|
} 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;
|
return;
|
||||||
const tlsOptions = {
|
const tlsOptions = {
|
||||||
pfx: [] as { buf: Buffer, passphrase?: string }[],
|
pfx: [] as { buf: Buffer, passphrase?: string }[],
|
||||||
key: [] as { pem: Buffer, passphrase?: string }[],
|
key: [] as { pem: Buffer, passphrase?: string }[],
|
||||||
cert: [] as Buffer[],
|
cert: [] as Buffer[],
|
||||||
};
|
};
|
||||||
for (const cert of matchingCerts) {
|
for (const cert of clientCertificates) {
|
||||||
if (cert.cert)
|
if (cert.cert)
|
||||||
tlsOptions.cert.push(cert.cert);
|
tlsOptions.cert.push(cert.cert);
|
||||||
if (cert.key)
|
if (cert.key)
|
||||||
|
|
@ -305,6 +317,16 @@ export function clientCertificatesToTLSOptions(
|
||||||
return tlsOptions;
|
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 {
|
function rewriteToLocalhostIfNeeded(host: string): string {
|
||||||
return host === 'local.playwright' ? 'localhost' : host;
|
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 }) => {
|
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 serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
|
||||||
const page = await browser.newPage({
|
await expect(browser.newPage({
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
clientCertificates: [{
|
clientCertificates: [{
|
||||||
origin: new URL(serverURL).origin,
|
origin: new URL(serverURL).origin,
|
||||||
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
|
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
|
||||||
passphrase: 'secure'
|
passphrase: 'secure'
|
||||||
}],
|
}],
|
||||||
});
|
})).rejects.toThrow('Unsupported TLS certificate');
|
||||||
await page.goto(serverURL);
|
|
||||||
await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible();
|
|
||||||
await page.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => {
|
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 serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
|
||||||
const page = await browser.newPage({
|
await expect(browser.newPage({
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
clientCertificates: [{
|
clientCertificates: [{
|
||||||
origin: new URL(serverURL).origin,
|
origin: new URL(serverURL).origin,
|
||||||
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
||||||
passphrase: 'this-password-is-incorrect'
|
passphrase: 'this-password-is-incorrect'
|
||||||
}],
|
}],
|
||||||
});
|
})).rejects.toThrow('Failed to load client certificate: mac verify failure');
|
||||||
await page.goto(serverURL);
|
|
||||||
await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible();
|
|
||||||
await page.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
|
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue