fix(client-certificates): return target errors on response when using http2 (#31867)

This commit is contained in:
Max Schmitt 2024-07-26 11:28:45 +02:00 committed by GitHub
parent 335d31bf65
commit 09581b615d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 56 additions and 12 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,17 +81,19 @@ 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;
private _targetCloseEventListener: () => void;
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
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._targetCloseEventListener = () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid });
} }
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', this._targetCloseEventListener);
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 +142,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 +156,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 +173,30 @@ 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 20+
// TODO: http2.performServerHandshake does not work here for some reason. if ('performServerHandshake' in http2) {
// 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.
this.target.removeListener('close', this._targetCloseEventListener);
// @ts-expect-error
const session: http2.ServerHttp2Session = 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();
});
stream.on('error', () => 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 +204,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 }[] = [];
@ -279,7 +280,8 @@ test.describe('browser', () => {
test('support http2', async ({ browser, startCCServer, asset, browserName }) => { test('support http2', async ({ browser, startCCServer, asset, browserName }) => {
test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost'); test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost');
const serverURL = await startCCServer({ http2: true }); const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux';
const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
@ -289,7 +291,7 @@ test.describe('browser', () => {
}); });
// TODO: We should investigate why http2 is not supported in WebKit on Linux. // TODO: We should investigate why http2 is not supported in WebKit on Linux.
// https://bugs.webkit.org/show_bug.cgi?id=276990 // https://bugs.webkit.org/show_bug.cgi?id=276990
const expectedProtocol = browserName === 'webkit' && process.platform === 'linux' ? 'http/1.1' : 'h2'; const expectedProtocol = enableHTTP1FallbackWhenUsingHttp2 ? 'http/1.1' : 'h2';
{ {
await page.goto(serverURL.replace('localhost', 'local.playwright')); await page.goto(serverURL.replace('localhost', 'local.playwright'));
await expect(page.getByTestId('message')).toHaveText('Sorry, but you need to provide a client certificate to continue.'); await expect(page.getByTestId('message')).toHaveText('Sorry, but you need to provide a client certificate to continue.');
@ -306,7 +308,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 +330,25 @@ 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');
test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions');
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();