mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(client-certificates): add support for proxies (#32611)
Fixes https://github.com/microsoft/playwright/issues/32370
This commit is contained in:
parent
b335b00a86
commit
21d162c945
@ -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`.
|
||||||
:::
|
:::
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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"`);
|
||||||
|
@ -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));
|
||||||
|
@ -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)
|
||||||
|
@ -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('*');
|
||||||
|
8
packages/playwright-core/types/types.d.ts
vendored
8
packages/playwright-core/types/types.d.ts
vendored
@ -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`.
|
||||||
*/
|
*/
|
||||||
|
2
packages/playwright/types/test.d.ts
vendored
2
packages/playwright/types/test.d.ts
vendored
@ -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`.
|
||||||
*
|
*
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user