Fix use of client certificates on hosts behind proxies.
- ALPNCache uses proxy and secure context. - Allow using resolve fixtures for client cert proxy and ALPN cache.
This commit is contained in:
parent
6b5098386a
commit
af1ccca8b1
|
|
@ -39,10 +39,15 @@ function loadDummyServerCertsIfNeeded() {
|
||||||
dummyServerTlsOptions = { key, cert };
|
dummyServerTlsOptions = { key, cert };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ALPNCacheOptions = {
|
||||||
|
socket?: stream.Duplex | undefined;
|
||||||
|
secureContext: tls.SecureContext | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
class ALPNCache {
|
class ALPNCache {
|
||||||
private _cache = new Map<string, ManualPromise<string>>();
|
private _cache = new Map<string, ManualPromise<string>>();
|
||||||
|
|
||||||
get(host: string, port: number, success: (protocol: string) => void) {
|
get(host: string, port: number, options: ALPNCacheOptions, success: (protocol: string) => void) {
|
||||||
const cacheKey = `${host}:${port}`;
|
const cacheKey = `${host}:${port}`;
|
||||||
{
|
{
|
||||||
const result = this._cache.get(cacheKey);
|
const result = this._cache.get(cacheKey);
|
||||||
|
|
@ -54,23 +59,56 @@ class ALPNCache {
|
||||||
const result = new ManualPromise<string>();
|
const result = new ManualPromise<string>();
|
||||||
this._cache.set(cacheKey, result);
|
this._cache.set(cacheKey, result);
|
||||||
result.then(success);
|
result.then(success);
|
||||||
createTLSSocket({
|
const fixtures = {
|
||||||
host,
|
__testHookLookup: (options as any).__testHookLookup
|
||||||
port,
|
};
|
||||||
servername: net.isIP(host) ? undefined : host,
|
|
||||||
ALPNProtocols: ['h2', 'http/1.1'],
|
if (!options.socket) {
|
||||||
rejectUnauthorized: false,
|
createTLSSocket({
|
||||||
}).then(socket => {
|
host,
|
||||||
// The server may not respond with ALPN, in which case we default to http/1.1.
|
port,
|
||||||
result.resolve(socket.alpnProtocol || 'http/1.1');
|
servername: net.isIP(host) ? undefined : host,
|
||||||
socket.end();
|
ALPNProtocols: ['h2', 'http/1.1'],
|
||||||
}).catch(error => {
|
rejectUnauthorized: false,
|
||||||
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
|
secureContext: options.secureContext,
|
||||||
result.resolve('http/1.1');
|
...fixtures,
|
||||||
});
|
}).then(socket => {
|
||||||
|
// The server may not respond with ALPN, in which case we default to http/1.1.
|
||||||
|
result.resolve(socket.alpnProtocol || 'http/1.1');
|
||||||
|
socket.end();
|
||||||
|
}).catch(error => {
|
||||||
|
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
|
||||||
|
result.resolve('http/1.1');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// a socket might be provided, for example, when using a proxy.
|
||||||
|
const socket = tls.connect({
|
||||||
|
socket: options.socket,
|
||||||
|
port: port,
|
||||||
|
host: host,
|
||||||
|
ALPNProtocols: ['h2', 'http/1.1'],
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
secureContext: options.secureContext,
|
||||||
|
servername: net.isIP(host) ? undefined : host
|
||||||
|
});
|
||||||
|
socket.on('secureConnect', () => {
|
||||||
|
result.resolve(socket.alpnProtocol || 'http/1.1');
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
socket.on('error', error => {
|
||||||
|
result.resolve('http/1.1');
|
||||||
|
});
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
result.resolve('http/1.1');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only used for fixtures
|
||||||
|
type SocksProxyConnectionOptions = {
|
||||||
|
};
|
||||||
|
|
||||||
class SocksProxyConnection {
|
class SocksProxyConnection {
|
||||||
private readonly socksProxy: ClientCertificatesProxy;
|
private readonly socksProxy: ClientCertificatesProxy;
|
||||||
private readonly uid: string;
|
private readonly uid: string;
|
||||||
|
|
@ -84,12 +122,14 @@ class SocksProxyConnection {
|
||||||
private _targetCloseEventListener: () => void;
|
private _targetCloseEventListener: () => void;
|
||||||
private _dummyServer: tls.Server | undefined;
|
private _dummyServer: tls.Server | undefined;
|
||||||
private _closed = false;
|
private _closed = false;
|
||||||
|
private _options: SocksProxyConnectionOptions;
|
||||||
|
|
||||||
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
|
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number, options: SocksProxyConnectionOptions) {
|
||||||
this.socksProxy = socksProxy;
|
this.socksProxy = socksProxy;
|
||||||
this.uid = uid;
|
this.uid = uid;
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
this._options = options;
|
||||||
this._targetCloseEventListener = () => {
|
this._targetCloseEventListener = () => {
|
||||||
// Close the other end and cleanup TLS resources.
|
// Close the other end and cleanup TLS resources.
|
||||||
this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid });
|
this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid });
|
||||||
|
|
@ -142,7 +182,7 @@ class SocksProxyConnection {
|
||||||
this.target.write(data);
|
this.target.write(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachTLSListeners() {
|
private async _attachTLSListeners() {
|
||||||
this.internal = new stream.Duplex({
|
this.internal = new stream.Duplex({
|
||||||
read: () => {},
|
read: () => {},
|
||||||
write: (data, encoding, callback) => {
|
write: (data, encoding, callback) => {
|
||||||
|
|
@ -150,7 +190,20 @@ class SocksProxyConnection {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => {
|
const secureContext = this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin);
|
||||||
|
const fixtures = {
|
||||||
|
__testHookLookup: (this._options as any).__testHookLookup
|
||||||
|
};
|
||||||
|
|
||||||
|
const alpnCacheOptions: ALPNCacheOptions = {
|
||||||
|
secureContext,
|
||||||
|
...fixtures
|
||||||
|
};
|
||||||
|
if (this.socksProxy.proxyAgentFromOptions)
|
||||||
|
alpnCacheOptions.socket = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
|
||||||
|
|
||||||
|
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnCacheOptions, alpnProtocolChosenByServer => {
|
||||||
|
alpnCacheOptions.socket?.destroy();
|
||||||
debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`);
|
debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`);
|
||||||
if (this._closed)
|
if (this._closed)
|
||||||
return;
|
return;
|
||||||
|
|
@ -221,7 +274,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: this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin),
|
secureContext: secureContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
targetTLS.once('secureConnect', () => {
|
targetTLS.once('secureConnect', () => {
|
||||||
|
|
@ -256,7 +309,9 @@ export class ClientCertificatesProxy {
|
||||||
this._socksProxy.setPattern('*');
|
this._socksProxy.setPattern('*');
|
||||||
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
|
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
|
||||||
try {
|
try {
|
||||||
const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port);
|
const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port, {
|
||||||
|
__testHookLookup: (contextOptions as any).__testHookLookup
|
||||||
|
});
|
||||||
await connection.connect();
|
await connection.connect();
|
||||||
this._connections.set(payload.uid, connection);
|
this._connections.set(payload.uid, connection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { expect, playwrightTest as base } from '../config/browserTest';
|
||||||
import type net from 'net';
|
import type net from 'net';
|
||||||
import type { BrowserContextOptions } from 'packages/playwright-test';
|
import type { BrowserContextOptions } from 'packages/playwright-test';
|
||||||
import { setupSocksForwardingServer } from '../config/proxy';
|
import { setupSocksForwardingServer } from '../config/proxy';
|
||||||
|
import { LookupAddress } from 'dns';
|
||||||
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
||||||
|
|
||||||
type TestOptions = {
|
type TestOptions = {
|
||||||
|
|
@ -371,6 +372,50 @@ test.describe('browser', () => {
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should pass with matching certificates and when a http proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, proxyServer, isMac }) => {
|
||||||
|
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
|
||||||
|
const serverPort = parseInt(new URL(serverURL).port, 10);
|
||||||
|
const privateDomain = `private.playwright.test`;
|
||||||
|
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
|
||||||
|
|
||||||
|
// make private domain resolve to unreachable server 192.0.2.0
|
||||||
|
// any attempt to connect there will timeout
|
||||||
|
let interceptedHostnameLookup: string | undefined;
|
||||||
|
const __testHookLookup = (hostname: string): LookupAddress[] => {
|
||||||
|
if (hostname === privateDomain) {
|
||||||
|
interceptedHostnameLookup = hostname;
|
||||||
|
return [
|
||||||
|
{ address: '192.0.2.0', family: 4 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
clientCertificates: [{
|
||||||
|
origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain),
|
||||||
|
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||||
|
keyPath: asset('client-certificates/client/trusted/key.pem'),
|
||||||
|
}],
|
||||||
|
proxy: { server: `localhost:${proxyServer.PORT}` },
|
||||||
|
...
|
||||||
|
{ __testHookLookup } as any
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
const requestURL = serverURL.replace('127.0.0.1', privateDomain);
|
||||||
|
expect(proxyServer.connectHosts).toEqual([]);
|
||||||
|
await page.goto(requestURL);
|
||||||
|
|
||||||
|
// only the proxy server should have tried to resolve the private domain
|
||||||
|
// and the test proxy server does not resolve domains
|
||||||
|
expect(interceptedHostnameLookup).toBe(undefined);
|
||||||
|
expect([...new Set(proxyServer.connectHosts)]).toEqual([`${privateDomain}:${serverPort}`]);
|
||||||
|
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName, isMac }) => {
|
test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName, isMac }) => {
|
||||||
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
|
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
|
||||||
const serverPort = parseInt(new URL(serverURL).port, 10);
|
const serverPort = parseInt(new URL(serverURL).port, 10);
|
||||||
|
|
@ -397,6 +442,55 @@ test.describe('browser', () => {
|
||||||
await closeProxyServer();
|
await closeProxyServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should pass with matching certificates and when a socks proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, isMac }) => {
|
||||||
|
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
|
||||||
|
const serverPort = parseInt(new URL(serverURL).port, 10);
|
||||||
|
const privateDomain = `private.playwright.test`;
|
||||||
|
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
|
||||||
|
port: test.info().workerIndex + 2048 + 2,
|
||||||
|
forwardPort: serverPort,
|
||||||
|
allowedTargetPort: serverPort,
|
||||||
|
additionalAllowedHosts: [privateDomain],
|
||||||
|
});
|
||||||
|
|
||||||
|
// make private domain resolve to unreachable server 192.0.2.0
|
||||||
|
// any attempt to connect will timeout
|
||||||
|
let interceptedHostnameLookup: string | undefined;
|
||||||
|
const __testHookLookup = (hostname: string): LookupAddress[] => {
|
||||||
|
if (hostname === privateDomain) {
|
||||||
|
interceptedHostnameLookup = hostname;
|
||||||
|
return [
|
||||||
|
{ address: '192.0.2.0', family: 4 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
clientCertificates: [{
|
||||||
|
origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain),
|
||||||
|
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||||
|
keyPath: asset('client-certificates/client/trusted/key.pem'),
|
||||||
|
}],
|
||||||
|
proxy: { server: proxyServerAddr },
|
||||||
|
...
|
||||||
|
{ __testHookLookup } as any
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
expect(connectHosts).toEqual([]);
|
||||||
|
const requestURL = serverURL.replace('127.0.0.1', privateDomain);
|
||||||
|
await page.goto(requestURL);
|
||||||
|
|
||||||
|
// only the proxy server should have tried to resolve the private domain
|
||||||
|
// and the test proxy server does not resolve domains
|
||||||
|
expect(interceptedHostnameLookup).toBe(undefined);
|
||||||
|
expect(connectHosts).toEqual([`${privateDomain}:${serverPort}`]);
|
||||||
|
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
|
||||||
|
await page.close();
|
||||||
|
await closeProxyServer();
|
||||||
|
});
|
||||||
|
|
||||||
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
|
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
|
||||||
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
|
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
|
||||||
await test.step(`TLS version: ${tlsVersion}`, async () => {
|
await test.step(`TLS version: ${tlsVersion}`, async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue