feat(client-certificates): add support for proxies (#32611)

Fixes https://github.com/microsoft/playwright/issues/32370
This commit is contained in:
Max Schmitt 2024-09-16 17:57:33 +02:00 committed by GitHub
parent b335b00a86
commit 21d162c945
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 196 additions and 103 deletions

View File

@ -558,10 +558,6 @@ TLS Client Authentication allows the server to request a client certificate and
An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for.
:::note
Using Client Certificates in combination with Proxy Servers is not supported.
:::
:::note :::note
When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`.
::: :::

View File

@ -16,7 +16,7 @@
import type * as types from './types'; import type * as types from './types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { BrowserContext, createClientCertificatesProxyIfNeeded, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { Page } from './page'; import { Page } from './page';
import { Download } from './download'; import { Download } from './download';
import type { ProxySettings } from './types'; import type { ProxySettings } from './types';
@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { Artifact } from './artifact'; import { Artifact } from './artifact';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export interface BrowserProcess { export interface BrowserProcess {
onclose?: ((exitCode: number | null, signal: string | null) => void); onclose?: ((exitCode: number | null, signal: string | null) => void);
@ -82,8 +83,10 @@ export abstract class Browser extends SdkObject {
async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> { async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options); validateBrowserContextOptions(options, this.options);
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (clientCertificatesProxy) { if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options);
options = { ...options };
options.proxyOverride = await clientCertificatesProxy.listen(); options.proxyOverride = await clientCertificatesProxy.listen();
options.internalIgnoreHTTPSErrors = true; options.internalIgnoreHTTPSErrors = true;
} }

View File

@ -42,7 +42,7 @@ import * as consoleApiSource from '../generated/consoleApiSource';
import { BrowserContextAPIRequestContext } from './fetch'; import { BrowserContextAPIRequestContext } from './fetch';
import type { Artifact } from './artifact'; import type { Artifact } from './artifact';
import { Clock } from './clock'; import { Clock } from './clock';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
export abstract class BrowserContext extends SdkObject { export abstract class BrowserContext extends SdkObject {
@ -659,15 +659,6 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) {
} }
} }
export async function createClientCertificatesProxyIfNeeded(options: types.BrowserContextOptions, browserOptions?: BrowserOptions) {
if (!options.clientCertificates?.length)
return;
if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context'))
throw new Error('Cannot specify both proxy and clientCertificates');
verifyClientCertificates(options.clientCertificates);
return new ClientCertificatesProxy(options);
}
export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);

View File

@ -18,7 +18,7 @@ import fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import path from 'path'; import path from 'path';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import { createClientCertificatesProxyIfNeeded, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext';
import type { BrowserName } from './registry'; import type { BrowserName } from './registry';
import { registry } from './registry'; import { registry } from './registry';
import type { ConnectionTransport } from './transport'; import type { ConnectionTransport } from './transport';
@ -39,6 +39,7 @@ import { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { type ProtocolError, isProtocolError } from './protocolError'; import { type ProtocolError, isProtocolError } from './protocolError';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' +
'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team'; 'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team';
@ -92,19 +93,23 @@ export abstract class BrowserType extends SdkObject {
return browser; return browser;
} }
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, persistentContextOptions: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise<BrowserContext> {
const launchOptions = this._validateLaunchOptions(persistentContextOptions); const launchOptions = this._validateLaunchOptions(options);
if (this._useBidi) if (this._useBidi)
launchOptions.useWebSocket = true; launchOptions.useWebSocket = true;
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
controller.setLogName('browser'); controller.setLogName('browser');
const browser = await controller.run(async progress => { const browser = await controller.run(async progress => {
// Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors.
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistentContextOptions); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (clientCertificatesProxy) if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options);
launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); launchOptions.proxyOverride = await clientCertificatesProxy?.listen();
options = { ...options };
options.internalIgnoreHTTPSErrors = true;
}
progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); progress.cleanupWhenAborted(() => clientCertificatesProxy?.close());
const browser = await this._innerLaunchWithRetries(progress, launchOptions, persistentContextOptions, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy;
return browser; return browser;
}, TimeoutSettings.launchTimeout(launchOptions)); }, TimeoutSettings.launchTimeout(launchOptions));

View File

@ -168,23 +168,11 @@ export abstract class APIRequestContext extends SdkObject {
const method = params.method?.toUpperCase() || 'GET'; const method = params.method?.toUpperCase() || 'GET';
const proxy = defaults.proxy; const proxy = defaults.proxy;
let agent; let agent;
// When `clientCertificates` is present, we set the `proxy` property to our own socks proxy // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
// for the browser to use. However, we don't need it here, because we already respect // workaround an upstream Chromium bug. Can be removed in the future.
// `clientCertificates` when fetching from Node.js. if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass))
if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { agent = createProxyAgent(proxy);
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
agent = new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
} else {
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
agent = new HttpsProxyAgent(proxyOpts);
}
}
const timeout = defaults.timeoutSettings.timeout(params); const timeout = defaults.timeoutSettings.timeout(params);
const deadline = timeout && (monotonicTime() + timeout); const deadline = timeout && (monotonicTime() + timeout);
@ -577,8 +565,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
if (!/^\w+:\/\//.test(url)) if (!/^\w+:\/\//.test(url))
url = 'http://' + url; url = 'http://' + url;
proxy.server = url; proxy.server = url;
if (options.clientCertificates)
throw new Error('Cannot specify both proxy and clientCertificates');
} }
if (options.storageState) { if (options.storageState) {
this._origins = options.storageState.origins; this._origins = options.storageState.origins;
@ -629,6 +615,20 @@ export class GlobalAPIRequestContext extends APIRequestContext {
} }
} }
export function createProxyAgent(proxy: types.ProxySettings) {
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyOpts);
}
function toHeadersArray(rawHeaders: string[]): types.HeadersArray { function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const result: types.HeadersArray = []; const result: types.HeadersArray = [];
for (let i = 0; i < rawHeaders.length; i += 2) for (let i = 0; i < rawHeaders.length; i += 2)

View File

@ -25,6 +25,9 @@ import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketReque
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as types from './types'; import type * as types from './types';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { createProxyAgent } from './fetch';
import { EventEmitter } from 'events';
import { verifyClientCertificates } from './browserContext';
let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
function loadDummyServerCertsIfNeeded() { function loadDummyServerCertsIfNeeded() {
@ -94,7 +97,11 @@ class SocksProxyConnection {
} }
async connect() { async connect() {
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); 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.once('close', this._targetCloseEventListener); this.target.once('close', this._targetCloseEventListener);
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
if (this._closed) { if (this._closed) {
@ -233,12 +240,15 @@ export class ClientCertificatesProxy {
ignoreHTTPSErrors: boolean | undefined; ignoreHTTPSErrors: boolean | undefined;
secureContextMap: Map<string, tls.SecureContext> = new Map(); secureContextMap: Map<string, tls.SecureContext> = new Map();
alpnCache: ALPNCache; alpnCache: ALPNCache;
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
constructor( constructor(
contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'> contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'>
) { ) {
verifyClientCertificates(contextOptions.clientCertificates);
this.alpnCache = new ALPNCache(); this.alpnCache = new ALPNCache();
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined;
this._initSecureContexts(contextOptions.clientCertificates); this._initSecureContexts(contextOptions.clientCertificates);
this._socksProxy = new SocksProxy(); this._socksProxy = new SocksProxy();
this._socksProxy.setPattern('*'); this._socksProxy.setPattern('*');

View File

@ -9202,8 +9202,6 @@ export interface Browser {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -13924,8 +13922,6 @@ export interface BrowserType<Unused = {}> {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -16350,8 +16346,6 @@ export interface APIRequest {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -20712,8 +20706,6 @@ export interface BrowserContextOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */

View File

@ -5211,8 +5211,6 @@ export interface PlaywrightTestOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
* *

View File

@ -15,9 +15,11 @@
*/ */
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import type { Socket } from 'net';
import type { ProxyServer } from '../third_party/proxy'; import type { ProxyServer } from '../third_party/proxy';
import { createProxy } from '../third_party/proxy'; import { createProxy } from '../third_party/proxy';
import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
export class TestProxy { export class TestProxy {
readonly PORT: number; readonly PORT: number;
@ -27,7 +29,7 @@ export class TestProxy {
requestUrls: string[] = []; requestUrls: string[] = [];
private readonly _server: ProxyServer; private readonly _server: ProxyServer;
private readonly _sockets = new Set<Socket>(); private readonly _sockets = new Set<net.Socket>();
private _handlers: { event: string, handler: (...args: any[]) => void }[] = []; private _handlers: { event: string, handler: (...args: any[]) => void }[] = [];
static async create(port: number): Promise<TestProxy> { static async create(port: number): Promise<TestProxy> {
@ -90,7 +92,7 @@ export class TestProxy {
this._server.prependListener(event, handler); this._server.prependListener(event, handler);
} }
private _onSocket(socket: Socket) { private _onSocket(socket: net.Socket) {
this._sockets.add(socket); this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server. // that tab closing aborts outgoing connections to the server.
@ -100,5 +102,46 @@ export class TestProxy {
}); });
socket.once('close', () => this._sockets.delete(socket)); socket.once('close', () => this._sockets.delete(socket));
} }
}
export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
}: {
port: number, forwardPort: number, allowedTargetPort: number
}) {
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) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
if (!connectHosts.includes(`${payload.host}:${payload.port}`))
connectHosts.push(`${payload.host}:${payload.port}`);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, 'localhost');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://localhost:${port}`,
connectHosts,
};
} }

View File

@ -23,6 +23,7 @@ import type http from 'http';
import { expect, playwrightTest as base } from '../config/browserTest'; 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';
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
type TestOptions = { type TestOptions = {
@ -88,14 +89,6 @@ const kValidationSubTests: [BrowserContextOptions, string][] = [
passphrase: kDummyFileName, passphrase: kDummyFileName,
}] }]
}, 'pfx is specified together with cert, key or passphrase'], }, 'pfx is specified together with cert, key or passphrase'],
[{
proxy: { server: 'http://localhost:8080' },
clientCertificates: [{
origin: 'test',
certPath: kDummyFileName,
keyPath: kDummyFileName,
}]
}, 'Cannot specify both proxy and clientCertificates'],
]; ];
test.describe('fetch', () => { test.describe('fetch', () => {
@ -180,6 +173,54 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => {
const serverURL = await startCCServer();
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).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([]);
const response = await request.get(serverURL);
expect(proxyServer.connectHosts).toEqual([new URL(serverURL).host]);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
});
test('pass with trusted client certificates and when a socks proxy is used', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
const response = await request.get(serverURL);
expect(connectHosts).toEqual([new URL(serverURL).host]);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
await closeProxyServer();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
@ -311,6 +352,50 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should pass with matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).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);
expect([...new Set(proxyServer.connectHosts)]).toEqual([`localhost:${new URL(serverURL).port}`]);
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 }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin', host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
await page.goto(serverURL);
expect(connectHosts).toEqual([`localhost:${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 () => {

View File

@ -14,10 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { setupSocksForwardingServer } from '../config/proxy';
import { playwrightTest as it, expect } from '../config/browserTest'; import { playwrightTest as it, expect } from '../config/browserTest';
import net from 'net'; import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
it.skip(({ mode }) => mode.startsWith('service')); it.skip(({ mode }) => mode.startsWith('service'));
@ -288,42 +287,13 @@ it('should use proxy with emulated user agent', async ({ browserType }) => {
expect(requestText).toContain('MyUserAgent'); expect(requestText).toContain('MyUserAgent');
}); });
async function setupSocksForwardingServer(port: number, forwardPort: number) {
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'].includes(payload.host) || payload.port !== 1337) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, 'localhost');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://localhost:${port}`,
};
}
it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { it('should use SOCKS proxy for websocket requests', async ({ browserType, server }) => {
const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer({
port: it.info().workerIndex + 2048 + 2,
forwardPort: server.PORT,
allowedTargetPort: 1337,
});
const browser = await browserType.launch({ const browser = await browserType.launch({
proxy: { proxy: {
server: proxyServerAddr, server: proxyServerAddr,