fix(client-certificates): return target errors via http2 response

This commit is contained in:
Max Schmitt 2024-07-25 22:59:51 +02:00
parent a02ed38e60
commit bb9a32f522
2 changed files with 52 additions and 10 deletions

View file

@ -16,6 +16,7 @@
import net from 'net'; import net from 'net';
import path from 'path'; import path from 'path';
import http2 from 'http2';
import type https from 'https'; import type https from 'https';
import fs from 'fs'; import fs from 'fs';
import tls from 'tls'; import tls from 'tls';
@ -80,6 +81,7 @@ class SocksProxyConnection {
target!: net.Socket; target!: net.Socket;
// In case of http, we just pipe data to the target socket and they are |undefined|. // In case of http, we just pipe data to the target socket and they are |undefined|.
internal: stream.Duplex | undefined; internal: stream.Duplex | undefined;
_interceptClose = false;
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
this.socksProxy = socksProxy; this.socksProxy = socksProxy;
@ -90,7 +92,12 @@ class SocksProxyConnection {
async connect() { async connect() {
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); this.target.on('close', () => {
// In case of an 'error' event on the target connection, we still need to perform the http2 handshake on the browser side.
// This is an async operation, so we need to intercept the close event to prevent the socket from being closed too early.
if (!this._interceptClose)
this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid });
});
this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
this.socksProxy._socksProxy.socketConnected({ this.socksProxy._socksProxy.socketConnected({
uid: this.uid, uid: this.uid,
@ -139,7 +146,6 @@ class SocksProxyConnection {
dummyServer.emit('connection', this.internal); dummyServer.emit('connection', this.internal);
dummyServer.on('secureConnection', internalTLS => { dummyServer.on('secureConnection', internalTLS => {
debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`);
internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }));
const tlsOptions: tls.ConnectionOptions = { const tlsOptions: tls.ConnectionOptions = {
socket: this.target, socket: this.target,
host: this.host, host: this.host,
@ -154,8 +160,10 @@ class SocksProxyConnection {
tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
const targetTLS = tls.connect(tlsOptions); const targetTLS = tls.connect(tlsOptions);
internalTLS.pipe(targetTLS); targetTLS.on('secureConnect', () => {
targetTLS.pipe(internalTLS); internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS);
});
// Handle close and errors // Handle close and errors
const closeBothSockets = () => { const closeBothSockets = () => {
@ -169,11 +177,27 @@ class SocksProxyConnection {
internalTLS.on('error', () => closeBothSockets()); internalTLS.on('error', () => closeBothSockets());
targetTLS.on('error', error => { targetTLS.on('error', error => {
debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`);
const responseBody = 'Playwright client-certificate error: ' + error.message;
if (internalTLS?.alpnProtocol === 'h2') { if (internalTLS?.alpnProtocol === 'h2') {
// https://github.com/nodejs/node/issues/46152 // This method is available only in Node.js 18+
// TODO: http2.performServerHandshake does not work here for some reason. if ('performServerHandshake' in http2) {
this._interceptClose = true;
// @ts-expect-error
const session = http2.performServerHandshake(internalTLS);
session.on('stream', (stream: http2.ServerHttp2Stream) => {
stream.respond({
'content-type': 'text/html',
[http2.constants.HTTP2_HEADER_STATUS]: 503,
});
stream.end(responseBody, () => {
session.close();
closeBothSockets();
});
});
} else {
closeBothSockets();
}
} else { } else {
const responseBody = 'Playwright client-certificate error: ' + error.message;
internalTLS.end([ internalTLS.end([
'HTTP/1.1 503 Internal Server Error', 'HTTP/1.1 503 Internal Server Error',
'Content-Type: text/html; charset=utf-8', 'Content-Type: text/html; charset=utf-8',
@ -181,8 +205,8 @@ class SocksProxyConnection {
'\r\n', '\r\n',
responseBody, responseBody,
].join('\r\n')); ].join('\r\n'));
closeBothSockets();
} }
closeBothSockets();
}); });
}); });
}); });

View file

@ -25,6 +25,7 @@ const { createHttpsServer, createHttp2Server } = require('../../packages/playwri
type TestOptions = { type TestOptions = {
startCCServer(options?: { startCCServer(options?: {
http2?: boolean; http2?: boolean;
enableHTTP1FallbackWhenUsingHttp2?: boolean;
useFakeLocalhost?: boolean; useFakeLocalhost?: boolean;
}): Promise<string>, }): Promise<string>,
}; };
@ -42,7 +43,7 @@ const test = base.extend<TestOptions>({
], ],
requestCert: true, requestCert: true,
rejectUnauthorized: false, rejectUnauthorized: false,
allowHTTP1: true, allowHTTP1: options?.enableHTTP1FallbackWhenUsingHttp2,
}, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => { }, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => {
const tlsSocket = req.socket as import('tls').TLSSocket; const tlsSocket = req.socket as import('tls').TLSSocket;
const parts: { key: string, value: any }[] = []; const parts: { key: string, value: any }[] = [];
@ -306,7 +307,7 @@ test.describe('browser', () => {
test('support http2 if the browser only supports http1.1', async ({ browserType, browserName, startCCServer, asset }) => { test('support http2 if the browser only supports http1.1', async ({ browserType, browserName, startCCServer, asset }) => {
test.skip(browserName !== 'chromium'); test.skip(browserName !== 'chromium');
const serverURL = await startCCServer({ http2: true }); const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true });
const browser = await browserType.launch({ args: ['--disable-http2'] }); const browser = await browserType.launch({ args: ['--disable-http2'] });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
@ -328,6 +329,23 @@ test.describe('browser', () => {
await browser.close(); await browser.close();
}); });
test('should return target connection errors when using http2', async ({ browser, startCCServer, asset, browserName }) => {
test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost');
test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990');
process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html');
const serverURL = await startCCServer({ http2: true });
const page = await browser.newPage({
clientCertificates: [{
origin: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com',
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
});
await page.goto(serverURL);
await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible();
await page.close();
});
test.describe('persistentContext', () => { test.describe('persistentContext', () => {
test('validate input', async ({ launchPersistent }) => { test('validate input', async ({ launchPersistent }) => {
test.slow(); test.slow();