Skip to content

bug(network): Use proxySocket to determine alpnProtocol #35990

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function loadDummyServerCertsIfNeeded() {
class ALPNCache {
private _cache = new Map<string, ManualPromise<string>>();

get(host: string, port: number, success: (protocol: string) => void) {
get(host: string, port: number, secureContext: tls.SecureContext | undefined, proxySocket: stream.Duplex | undefined, success: (protocol: string) => void) {
const cacheKey = `${host}:${port}`;
{
const result = this._cache.get(cacheKey);
Expand All @@ -55,11 +55,13 @@ class ALPNCache {
this._cache.set(cacheKey, result);
result.then(success);
createTLSSocket({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @mxschmitt , Sharing some insights while developing this PR. I tried re-using the same function with socket, but it times out. Reason being, we dont have a secureConnect listener here:

// Each socket may fire only one of 'connect', 'timeout' or 'error' events.
// None of these events are fired after socket.destroy() is called.
socket.on('connect', () => {
(socket as any)[kTCPConnectionAt] = monotonicTime();
connected.resolve();
oncreate?.(null, socket);
// TODO: Cache the result?
// Close other outstanding sockets.
sockets.delete(socket);
for (const s of sockets)
s.destroy();
sockets.clear();
});
socket.on('timeout', () => {
// Timeout is not an error, so we have to manually close the socket.
socket.destroy();
handleError(socket, new Error('Connection timeout'));
});
socket.on('error', e => handleError(socket, e));
. Ideally, when it attempts to create connection here:
tls.connect({
...(options as tls.ConnectionOptions),
port: options.port as number,
host: address,
servername: hostname }) :
it throws a secureConnect event which isn't present. Instead, we rely on listening secureConnect event here:
socket.on('secureConnect', () => resolve(socket));
socket.on('error', error => reject(error));
that doesn't work because 2 subsequent events cannot be piped.
This required a bit of refactoring, hence avoided that change.
Looking at the recent changes you made, looks awesome. Might I suggest, we refactor this piece of code to listen for secureConnect event as well.
Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, Let me know if this makes sense. Or if you have an approach in mind I can follow to fix this issue?

socket: proxySocket,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this mutually exclusive with (host, port), if so can we provide either one or the other? Or if they are required, let's comment why.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internally, this method calls tls.connect.

tls.connect({
...(options as tls.ConnectionOptions),
port: options.port as number,
host: address,
servername: hostname }) :

yes, socket param is mutually exclusive with host, port and path params. Refer Documentation. Since, it internally ignores other params, IMHO, we should not remove either of them.

Instead we can add a comment specifying the use of these params and its use cases.

host,
port,
servername: net.isIP(host) ? undefined : host,
ALPNProtocols: ['h2', 'http/1.1'],
rejectUnauthorized: false,
secureContext,
}).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');
Expand Down Expand Up @@ -99,11 +101,7 @@ class SocksProxyConnection {
}

async connect() {
if (this.socksProxy.proxyAgentFromOptions)
this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
else
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);

this.target = await this._createProxySocket() ?? await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target.once('close', this._targetCloseEventListener);
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
if (this._closed) {
Expand Down Expand Up @@ -142,15 +140,23 @@ class SocksProxyConnection {
this.target.write(data);
}

private _attachTLSListeners() {
private async _createProxySocket() {
if (this.socksProxy.proxyAgentFromOptions)
return await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
}

private async _attachTLSListeners() {
this.internal = new stream.Duplex({
read: () => {},
read: () => { },
write: (data, encoding, callback) => {
this.socksProxy._socksProxy.sendSocketData({ uid: this.uid, data });
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 proxySocket = await this._createProxySocket();
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, secureContext, proxySocket, alpnProtocolChosenByServer => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this sent to the proxy and now the negotiation happens between the proxy server and the client?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the job of proxySocket is just to establish the connection. The negotiation still happens between client and destination server.

proxySocket?.destroy();
debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`);
if (this._closed)
return;
Expand Down Expand Up @@ -221,7 +227,7 @@ class SocksProxyConnection {
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
servername: !net.isIP(this.host) ? this.host : undefined,
secureContext: this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin),
secureContext,
});

targetTLS.once('secureConnect', () => {
Expand Down
13 changes: 10 additions & 3 deletions packages/playwright-core/src/server/utils/happyEyeballs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export async function createTLSSocket(options: tls.ConnectionOptions): Promise<t
createConnectionAsync(options, (err, socket) => {
if (err)
reject(err);

if (socket && options.socket !== undefined && socket.authorized !== undefined && socket.readyState === 'open')
resolve(socket);

if (socket) {
socket.on('secureConnect', () => resolve(socket));
socket.on('error', error => reject(error));
Expand Down Expand Up @@ -140,9 +144,9 @@ export async function createConnectionAsync(

(socket as any)[kDNSLookupAt] = dnsLookupAt;

// Each socket may fire only one of 'connect', 'timeout' or 'error' events.
// Each socket may fire only one of 'secureConnect' 'connect', 'timeout' or 'error' events.
// None of these events are fired after socket.destroy() is called.
socket.on('connect', () => {
const handleSocketConnect = () => {
(socket as any)[kTCPConnectionAt] = monotonicTime();

connected.resolve();
Expand All @@ -153,7 +157,10 @@ export async function createConnectionAsync(
for (const s of sockets)
s.destroy();
sockets.clear();
});
};

socket.on('connect', handleSocketConnect);
socket.on('secureConnect', handleSocketConnect);
socket.on('timeout', () => {
// Timeout is not an error, so we have to manually close the socket.
socket.destroy();
Expand Down
6 changes: 3 additions & 3 deletions tests/config/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,16 @@ export class TestProxy {
}

export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
port, forwardPort, allowedTargetPort, additionalAllowedHosts = []
}: {
port: number, forwardPort: number, allowedTargetPort: number
port: number, forwardPort: number, allowedTargetPort: number, additionalAllowedHosts?: string[]
}) {
const connectHosts = [];
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost', ...additionalAllowedHosts].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
Expand Down
72 changes: 71 additions & 1 deletion tests/library/client-certificates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { expect, playwrightTest as base } from '../config/browserTest';
import type net from 'net';
import type { BrowserContextOptions } from 'packages/playwright-test';
import { setupSocksForwardingServer } from '../config/proxy';
import type { LookupAddress } from 'dns';
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');

type TestOptions = {
Expand Down Expand Up @@ -371,6 +372,75 @@ test.describe('browser', () => {
await page.close();
});

test('should fail with non-matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL('https://abcd.efgh').origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` }
});
expect(proxyServer.connectHosts).toEqual([]);
await page.goto(serverURL);
const host = browserName === 'webkit' && isMac ? 'localhost' : '127.0.0.1';
expect([...new Set(proxyServer.connectHosts)]).toEqual([`${host}:${new URL(serverURL).port}`]);
await expect(page.getByTestId('message')).toHaveText('Sorry, but you need to provide a client certificate to continue.');
await page.close();
});

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 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 serverPort = parseInt(new URL(serverURL).port, 10);
Expand Down Expand Up @@ -771,7 +841,7 @@ test.describe('browser', () => {
const page = await context.newPage();

// This was triggering an unhandled error before.
await page.goto(serverUrl).catch(() => {});
await page.goto(serverUrl).catch(() => { });

await context.close();
await new Promise<void>(resolve => server.close(() => resolve()));
Expand Down
Loading