feat(client-certificates): add http2 support (#31786)
This commit is contained in:
parent
c74843a914
commit
c5b7ce86dc
|
|
@ -21,10 +21,46 @@ import fs from 'fs';
|
||||||
import tls from 'tls';
|
import tls from 'tls';
|
||||||
import stream from 'stream';
|
import stream from 'stream';
|
||||||
import { createSocket } from '../utils/happy-eyeballs';
|
import { createSocket } from '../utils/happy-eyeballs';
|
||||||
import { globToRegex, isUnderTest } from '../utils';
|
import { globToRegex, isUnderTest, ManualPromise } from '../utils';
|
||||||
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
|
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
|
||||||
import { SocksProxy } from '../common/socksProxy';
|
import { SocksProxy } from '../common/socksProxy';
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
|
|
||||||
|
class ALPNCache {
|
||||||
|
private _cache = new Map<string, ManualPromise<string>>();
|
||||||
|
|
||||||
|
get(host: string, port: number, success: (protocol: string) => void) {
|
||||||
|
const cacheKey = `${host}:${port}`;
|
||||||
|
{
|
||||||
|
const result = this._cache.get(cacheKey);
|
||||||
|
if (result) {
|
||||||
|
result.then(success);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = new ManualPromise<string>();
|
||||||
|
this._cache.set(cacheKey, result);
|
||||||
|
result.then(success);
|
||||||
|
const socket = tls.connect({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
servername: net.isIP(host) ? undefined : host,
|
||||||
|
ALPNProtocols: ['h2', 'http/1.1'],
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
socket.on('secureConnect', () => {
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
socket.on('error', error => {
|
||||||
|
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
|
||||||
|
result.resolve('http/1.1');
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SocksProxyConnection {
|
class SocksProxyConnection {
|
||||||
private readonly socksProxy: ClientCertificatesProxy;
|
private readonly socksProxy: ClientCertificatesProxy;
|
||||||
|
|
@ -44,7 +80,7 @@ class SocksProxyConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
this.target = await createSocket(this.host === 'local.playwright' ? 'localhost' : 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.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({
|
||||||
|
|
@ -84,50 +120,62 @@ class SocksProxyConnection {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const dummyServer = tls.createServer({
|
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => {
|
||||||
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')),
|
debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`);
|
||||||
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')),
|
const dummyServer = tls.createServer({
|
||||||
});
|
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')),
|
||||||
dummyServer.emit('connection', this.internal);
|
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')),
|
||||||
dummyServer.on('secureConnection', internalTLS => {
|
ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'],
|
||||||
internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }));
|
});
|
||||||
|
this.internal?.on('close', () => dummyServer.close());
|
||||||
|
dummyServer.emit('connection', this.internal);
|
||||||
|
dummyServer.on('secureConnection', internalTLS => {
|
||||||
|
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 = {
|
||||||
|
socket: this.target,
|
||||||
|
host: this.host,
|
||||||
|
port: this.port,
|
||||||
|
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
|
||||||
|
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
|
||||||
|
...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`),
|
||||||
|
};
|
||||||
|
if (!net.isIP(this.host))
|
||||||
|
tlsOptions.servername = this.host;
|
||||||
|
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
|
||||||
|
tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
|
||||||
|
const targetTLS = tls.connect(tlsOptions);
|
||||||
|
|
||||||
const tlsOptions: tls.ConnectionOptions = {
|
internalTLS.pipe(targetTLS);
|
||||||
socket: this.target,
|
targetTLS.pipe(internalTLS);
|
||||||
host: this.host,
|
|
||||||
port: this.port,
|
|
||||||
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
|
|
||||||
...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`),
|
|
||||||
};
|
|
||||||
if (!net.isIP(this.host))
|
|
||||||
tlsOptions.servername = this.host;
|
|
||||||
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
|
|
||||||
tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
|
|
||||||
const targetTLS = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
internalTLS.pipe(targetTLS);
|
// Handle close and errors
|
||||||
targetTLS.pipe(internalTLS);
|
const closeBothSockets = () => {
|
||||||
|
internalTLS.end();
|
||||||
|
targetTLS.end();
|
||||||
|
};
|
||||||
|
|
||||||
// Handle close and errors
|
internalTLS.on('end', () => closeBothSockets());
|
||||||
const closeBothSockets = () => {
|
targetTLS.on('end', () => closeBothSockets());
|
||||||
internalTLS.end();
|
|
||||||
targetTLS.end();
|
|
||||||
};
|
|
||||||
|
|
||||||
internalTLS.on('end', () => closeBothSockets());
|
internalTLS.on('error', () => closeBothSockets());
|
||||||
targetTLS.on('end', () => closeBothSockets());
|
targetTLS.on('error', error => {
|
||||||
|
debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`);
|
||||||
internalTLS.on('error', () => closeBothSockets());
|
if (internalTLS?.alpnProtocol === 'h2') {
|
||||||
targetTLS.on('error', error => {
|
// https://github.com/nodejs/node/issues/46152
|
||||||
const responseBody = 'Playwright client-certificate error: ' + error.message;
|
// TODO: http2.performServerHandshake does not work here for some reason.
|
||||||
internalTLS.end([
|
} else {
|
||||||
'HTTP/1.1 503 Internal Server Error',
|
const responseBody = 'Playwright client-certificate error: ' + error.message;
|
||||||
'Content-Type: text/html; charset=utf-8',
|
internalTLS.end([
|
||||||
'Content-Length: ' + Buffer.byteLength(responseBody),
|
'HTTP/1.1 503 Internal Server Error',
|
||||||
'\r\n',
|
'Content-Type: text/html; charset=utf-8',
|
||||||
responseBody,
|
'Content-Length: ' + Buffer.byteLength(responseBody),
|
||||||
].join('\r\n'));
|
'\r\n',
|
||||||
closeBothSockets();
|
responseBody,
|
||||||
|
].join('\r\n'));
|
||||||
|
}
|
||||||
|
closeBothSockets();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -138,10 +186,12 @@ export class ClientCertificatesProxy {
|
||||||
private _connections: Map<string, SocksProxyConnection> = new Map();
|
private _connections: Map<string, SocksProxyConnection> = new Map();
|
||||||
ignoreHTTPSErrors: boolean | undefined;
|
ignoreHTTPSErrors: boolean | undefined;
|
||||||
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'];
|
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'];
|
||||||
|
alpnCache: ALPNCache;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'>
|
contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'>
|
||||||
) {
|
) {
|
||||||
|
this.alpnCache = new ALPNCache();
|
||||||
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
|
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
|
||||||
this.clientCertificates = contextOptions.clientCertificates;
|
this.clientCertificates = contextOptions.clientCertificates;
|
||||||
this._socksProxy = new SocksProxy();
|
this._socksProxy = new SocksProxy();
|
||||||
|
|
@ -178,7 +228,7 @@ const kClientCertificatesGlobRegex = Symbol('kClientCertificatesGlobRegex');
|
||||||
|
|
||||||
export function clientCertificatesToTLSOptions(
|
export function clientCertificatesToTLSOptions(
|
||||||
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'],
|
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'],
|
||||||
requestURL: string
|
origin: string
|
||||||
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
|
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
|
||||||
const matchingCerts = clientCertificates?.filter(c => {
|
const matchingCerts = clientCertificates?.filter(c => {
|
||||||
let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex];
|
let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex];
|
||||||
|
|
@ -187,7 +237,7 @@ export function clientCertificatesToTLSOptions(
|
||||||
(c as any)[kClientCertificatesGlobRegex] = regex;
|
(c as any)[kClientCertificatesGlobRegex] = regex;
|
||||||
}
|
}
|
||||||
regex.lastIndex = 0;
|
regex.lastIndex = 0;
|
||||||
return regex.test(requestURL);
|
return regex.test(origin);
|
||||||
});
|
});
|
||||||
if (!matchingCerts || !matchingCerts.length)
|
if (!matchingCerts || !matchingCerts.length)
|
||||||
return;
|
return;
|
||||||
|
|
@ -206,3 +256,7 @@ export function clientCertificatesToTLSOptions(
|
||||||
}
|
}
|
||||||
return tlsOptions;
|
return tlsOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rewriteToLocalhostIfNeeded(host: string): string {
|
||||||
|
return host === 'local.playwright' ? 'localhost' : host;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const debugLoggerColorMap = {
|
||||||
'download': 34, // green
|
'download': 34, // green
|
||||||
'browser': 0, // reset
|
'browser': 0, // reset
|
||||||
'socks': 92, // purple
|
'socks': 92, // purple
|
||||||
|
'client-certificates': 92, // purple
|
||||||
'error': 160, // red,
|
'error': 160, // red,
|
||||||
'channel': 33, // blue
|
'channel': 33, // blue
|
||||||
'server': 45, // cyan
|
'server': 45, // cyan
|
||||||
|
|
|
||||||
|
|
@ -42,27 +42,30 @@ const test = base.extend<TestOptions>({
|
||||||
],
|
],
|
||||||
requestCert: true,
|
requestCert: true,
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
|
allowHTTP1: true,
|
||||||
}, (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;
|
||||||
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336
|
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336
|
||||||
expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true);
|
expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true);
|
||||||
|
const prefix = `ALPN protocol: ${tlsSocket.alpnProtocol}\n`;
|
||||||
const cert = tlsSocket.getPeerCertificate();
|
const cert = tlsSocket.getPeerCertificate();
|
||||||
if (tlsSocket.authorized) {
|
if (tlsSocket.authorized) {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`);
|
res.end(prefix + `Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`);
|
||||||
} else if (cert.subject) {
|
} else if (cert.subject) {
|
||||||
res.writeHead(403, { 'Content-Type': 'text/html' });
|
res.writeHead(403, { 'Content-Type': 'text/html' });
|
||||||
res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`);
|
res.end(prefix + `Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`);
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(401, { 'Content-Type': 'text/html' });
|
res.writeHead(401, { 'Content-Type': 'text/html' });
|
||||||
res.end(`Sorry, but you need to provide a client certificate to continue.`);
|
res.end(prefix + `Sorry, but you need to provide a client certificate to continue.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await new Promise<void>(f => server.listen(0, 'localhost', () => f()));
|
await new Promise<void>(f => server.listen(0, 'localhost', () => f()));
|
||||||
const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost';
|
const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost';
|
||||||
return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
|
return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
|
||||||
});
|
});
|
||||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
if (server)
|
||||||
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,7 +113,7 @@ test.describe('fetch', () => {
|
||||||
const request = await playwright.request.newContext();
|
const request = await playwright.request.newContext();
|
||||||
const response = await request.get(serverURL);
|
const response = await request.get(serverURL);
|
||||||
expect(response.status()).toBe(401);
|
expect(response.status()).toBe(401);
|
||||||
expect(await response.text()).toBe('Sorry, but you need to provide a client certificate to continue.');
|
expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.');
|
||||||
await request.dispose();
|
await request.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -141,7 +144,7 @@ test.describe('fetch', () => {
|
||||||
const response = await request.get(serverURL);
|
const response = await request.get(serverURL);
|
||||||
expect(response.url()).toBe(serverURL);
|
expect(response.url()).toBe(serverURL);
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
expect(await response.text()).toBe('Sorry Bob, certificates from Bob are not welcome here.');
|
expect(await response.text()).toContain('Sorry Bob, certificates from Bob are not welcome here.');
|
||||||
await request.dispose();
|
await request.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -157,7 +160,7 @@ test.describe('fetch', () => {
|
||||||
const response = await request.get(serverURL);
|
const response = await request.get(serverURL);
|
||||||
expect(response.url()).toBe(serverURL);
|
expect(response.url()).toBe(serverURL);
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
expect(await response.text()).toBe('Hello Alice, your certificate was issued by localhost!');
|
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
|
||||||
await request.dispose();
|
await request.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -258,6 +261,56 @@ test.describe('browser', () => {
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('support http2', async ({ browser, startCCServer, asset, browserName }) => {
|
||||||
|
test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost');
|
||||||
|
const serverURL = await startCCServer({ http2: true });
|
||||||
|
const page = await browser.newPage({
|
||||||
|
clientCertificates: [{
|
||||||
|
origin: new URL(serverURL).origin,
|
||||||
|
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||||
|
keyPath: asset('client-certificates/client/trusted/key.pem'),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
// TODO: We should investigate why http2 is not supported in WebKit on Linux.
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=276990
|
||||||
|
const expectedProtocol = browserName === 'webkit' && process.platform === 'linux' ? 'http/1.1' : 'h2';
|
||||||
|
{
|
||||||
|
await page.goto(serverURL.replace('localhost', 'local.playwright'));
|
||||||
|
await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible();
|
||||||
|
await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
await page.goto(serverURL);
|
||||||
|
await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
|
||||||
|
await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support http2 if the browser only supports http1.1', async ({ browserType, browserName, startCCServer, asset }) => {
|
||||||
|
test.skip(browserName !== 'chromium');
|
||||||
|
const serverURL = await startCCServer({ http2: true });
|
||||||
|
const browser = await browserType.launch({ args: ['--disable-http2'] });
|
||||||
|
const page = await browser.newPage({
|
||||||
|
clientCertificates: [{
|
||||||
|
origin: new URL(serverURL).origin,
|
||||||
|
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||||
|
keyPath: asset('client-certificates/client/trusted/key.pem'),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
{
|
||||||
|
await page.goto(serverURL.replace('localhost', 'local.playwright'));
|
||||||
|
await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible();
|
||||||
|
await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
await page.goto(serverURL);
|
||||||
|
await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
|
||||||
|
await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible();
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('persistentContext', () => {
|
test.describe('persistentContext', () => {
|
||||||
test('validate input', async ({ launchPersistent }) => {
|
test('validate input', async ({ launchPersistent }) => {
|
||||||
test.slow();
|
test.slow();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue