feat: support proxy in connect/connectOverCDP (#35389)

This commit is contained in:
Dmitry Gozman 2025-03-28 12:03:54 +00:00 committed by GitHub
parent e3bb687cfc
commit 471a28e0d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 365 additions and 94 deletions

View File

@ -144,6 +144,16 @@ Some common examples:
1. `"<loopback>"` to expose localhost network. 1. `"<loopback>"` to expose localhost network.
1. `"*.test.internal-domain,*.staging.internal-domain,<loopback>"` to expose test/staging deployments and localhost. 1. `"*.test.internal-domain,*.staging.internal-domain,<loopback>"` to expose test/staging deployments and localhost.
### option: BrowserType.connect.proxy
* since: v1.52
- `proxy` <[Object]>
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.
Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.
## async method: BrowserType.connectOverCDP ## async method: BrowserType.connectOverCDP
* since: v1.9 * since: v1.9
- returns: <[Browser]> - returns: <[Browser]>
@ -232,6 +242,16 @@ Logger sink for Playwright logging. Optional.
Maximum time in milliseconds to wait for the connection to be established. Defaults to Maximum time in milliseconds to wait for the connection to be established. Defaults to
`30000` (30 seconds). Pass `0` to disable timeout. `30000` (30 seconds). Pass `0` to disable timeout.
### option: BrowserType.connectOverCDP.proxy
* since: v1.52
- `proxy` <[Object]>
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.
Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.
## method: BrowserType.executablePath ## method: BrowserType.executablePath
* since: v1.8 * since: v1.8
- returns: <[string]> - returns: <[string]>

View File

@ -226,10 +226,8 @@ Dangerous option; use with care. Defaults to `false`.
## browser-option-proxy ## browser-option-proxy
- `proxy` <[Object]> - `proxy` <[Object]>
- `server` <[string]> Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example - `server` <[string]> Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example
`http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
proxy. - `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org,
.domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication. - `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication. - `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.

View File

@ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions {
*/ */
logger?: Logger; logger?: Logger;
/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;
/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;
/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;
/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};
/** /**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0. * on. Defaults to 0.
@ -21834,6 +21862,34 @@ export interface ConnectOptions {
*/ */
logger?: Logger; logger?: Logger;
/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;
/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;
/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;
/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};
/** /**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0. * on. Defaults to 0.

View File

@ -129,6 +129,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
exposeNetwork: params.exposeNetwork ?? params._exposeNetwork, exposeNetwork: params.exposeNetwork ?? params._exposeNetwork,
slowMo: params.slowMo, slowMo: params.slowMo,
timeout: params.timeout, timeout: params.timeout,
proxy: params.proxy,
}; };
if ((params as any).__testHookRedirectPortForwarding) if ((params as any).__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
@ -188,7 +189,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
endpointURL, endpointURL,
headers, headers,
slowMo: params.slowMo, slowMo: params.slowMo,
timeout: params.timeout timeout: params.timeout,
proxy: params.proxy,
}); });
const browser = Browser.from(result.browser); const browser = Browser.from(result.browser);
this._didLaunchBrowser(browser, {}, params.logger); this._didLaunchBrowser(browser, {}, params.logger);

View File

@ -103,6 +103,12 @@ export type ConnectOptions = {
slowMo?: number, slowMo?: number,
timeout?: number, timeout?: number,
logger?: Logger, logger?: Logger,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string
},
}; };
export type LaunchServerOptions = { export type LaunchServerOptions = {
channel?: channels.BrowserTypeLaunchOptions['channel'], channel?: channels.BrowserTypeLaunchOptions['channel'],

View File

@ -327,6 +327,12 @@ scheme.LocalUtilsConnectParams = tObject({
exposeNetwork: tOptional(tString), exposeNetwork: tOptional(tString),
slowMo: tOptional(tNumber), slowMo: tOptional(tNumber),
timeout: tOptional(tNumber), timeout: tOptional(tNumber),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
username: tOptional(tString),
password: tOptional(tString),
})),
socksProxyRedirectPortForTest: tOptional(tNumber), socksProxyRedirectPortForTest: tOptional(tNumber),
}); });
scheme.LocalUtilsConnectResult = tObject({ scheme.LocalUtilsConnectResult = tObject({
@ -650,6 +656,12 @@ scheme.BrowserTypeConnectOverCDPParams = tObject({
headers: tOptional(tArray(tType('NameValue'))), headers: tOptional(tArray(tType('NameValue'))),
slowMo: tOptional(tNumber), slowMo: tOptional(tNumber),
timeout: tOptional(tNumber), timeout: tOptional(tNumber),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
username: tOptional(tString),
password: tOptional(tString),
})),
}); });
scheme.BrowserTypeConnectOverCDPResult = tObject({ scheme.BrowserTypeConnectOverCDPResult = tObject({
browser: tChannel(['Browser']), browser: tChannel(['Browser']),

View File

@ -292,7 +292,7 @@ export abstract class BrowserType extends SdkObject {
await fs.promises.mkdir(options.tracesDir, { recursive: true }); await fs.promises.mkdir(options.tracesDir, { recursive: true });
} }
async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number }, timeout?: number): Promise<Browser> { async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }): Promise<Browser> {
throw new Error('CDP connections are only supported by Chromium'); throw new Error('CDP connections are only supported by Chromium');
} }

View File

@ -64,15 +64,15 @@ export class Chromium extends BrowserType {
this._devtools = this._createDevTools(); this._devtools = this._createDevTools();
} }
override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }, timeout?: number) { override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
controller.setLogName('browser'); controller.setLogName('browser');
return controller.run(async progress => { return controller.run(async progress => {
return await this._connectOverCDPInternal(progress, endpointURL, options); return await this._connectOverCDPInternal(progress, endpointURL, options);
}, TimeoutSettings.timeout({ timeout })); }, TimeoutSettings.timeout(options));
} }
async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise<void>) { async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray, proxy?: types.ProxySettings }, onClose?: () => Promise<void>) {
let headersMap: { [key: string]: string; } | undefined; let headersMap: { [key: string]: string; } | undefined;
if (options.headers) if (options.headers)
headersMap = headersArrayToObject(options.headers, false); headersMap = headersArrayToObject(options.headers, false);
@ -84,10 +84,10 @@ export class Chromium extends BrowserType {
const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);
const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap); const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap, options.proxy);
progress.throwIfAborted(); progress.throwIfAborted();
const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, headersMap); const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap, proxy: options.proxy });
const cleanedUp = new ManualPromise<void>(); const cleanedUp = new ManualPromise<void>();
const doCleanup = async () => { const doCleanup = async () => {
await removeFolders([artifactsDir]); await removeFolders([artifactsDir]);
@ -365,18 +365,35 @@ class ChromiumReadyState extends BrowserReadyState {
} }
} }
async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) { async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }, proxy?: types.ProxySettings) {
if (endpointURL.startsWith('ws')) if (endpointURL.startsWith('ws'))
return endpointURL; return endpointURL;
progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`); progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`; const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`;
// Chromium insists on localhost "Host" header for security reasons, and in the case of proxy
// we end up with the remote host instead of localhost.
const extraHeaders = proxy ? { Host: `localhost:9222` } : {};
const json = await fetchData({ const json = await fetchData({
url: httpURL, url: httpURL,
headers, headers: { ...headers, ...extraHeaders },
proxy,
}, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` + }, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` +
`This does not look like a DevTools server, try connecting via ws://.`) `This does not look like a DevTools server, try connecting via ws://.`)
); );
return JSON.parse(json).webSocketDebuggerUrl; const wsUrl = JSON.parse(json).webSocketDebuggerUrl;
if (proxy) {
// webSocketDebuggerUrl will be a localhost URL, accessible from the browser's computer.
// When using a proxy, assume we need to connect through the original endpointURL.
// Example:
// connecting to http://example.com/
// making request to http://example.com/json/version/
// webSocketDebuggerUrl ends up as ws://localhost:9222/devtools/page/<guid>
// we construct ws://example.com/devtools/page/<guid> by appending the pathname to the original URL
const url = new URL(endpointURL);
url.pathname += (url.pathname.endsWith('/') ? '' : '/') + new URL(wsUrl).pathname.substring(1);
return url.toString();
}
return wsUrl;
} }
async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) { async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) {

View File

@ -43,7 +43,7 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
} }
async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> { async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> {
const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params, params.timeout); const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params);
const browserDispatcher = new BrowserDispatcher(this, browser); const browserDispatcher = new BrowserDispatcher(this, browser);
return { return {
browser: browserDispatcher, browser: browserDispatcher,

View File

@ -33,6 +33,7 @@ import type { RootDispatcher } from './dispatcher';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type * as http from 'http'; import type * as http from 'http';
import type { HTTPRequestParams } from '../utils/network'; import type { HTTPRequestParams } from '../utils/network';
import type { ProxySettings } from '../types';
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean; _type_LocalUtils: boolean;
@ -90,9 +91,9 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
'x-playwright-proxy': params.exposeNetwork ?? '', 'x-playwright-proxy': params.exposeNetwork ?? '',
...params.headers, ...params.headers,
}; };
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint, params.proxy);
const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true, 'x-playwright-debug-log'); const transport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: wsHeaders, followRedirects: true, debugLogHeader: 'x-playwright-debug-log', proxy: params.proxy });
const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest); const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest);
const pipe = new JsonPipeDispatcher(this); const pipe = new JsonPipeDispatcher(this);
transport.onmessage = json => { transport.onmessage = json => {
@ -128,7 +129,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
} }
} }
async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string): Promise<string> { async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string, proxy?: ProxySettings): Promise<string> {
if (endpointURL.startsWith('ws')) if (endpointURL.startsWith('ws'))
return endpointURL; return endpointURL;
@ -142,6 +143,7 @@ async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: stri
method: 'GET', method: 'GET',
timeout: progress?.timeUntilDeadline() ?? 30_000, timeout: progress?.timeUntilDeadline() ?? 30_000,
headers: { 'User-Agent': getUserAgent() }, headers: { 'User-Agent': getUserAgent() },
proxy,
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => { }, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
`This does not look like a Playwright server, try connecting via ws://.`); `This does not look like a Playwright server, try connecting via ws://.`);

View File

@ -18,14 +18,12 @@ import http from 'http';
import https from 'https'; import https from 'https';
import { Transform, pipeline } from 'stream'; import { Transform, pipeline } from 'stream';
import { TLSSocket } from 'tls'; import { TLSSocket } from 'tls';
import url from 'url';
import * as zlib from 'zlib'; import * as zlib from 'zlib';
import { TimeoutSettings } from './timeoutSettings'; import { TimeoutSettings } from './timeoutSettings';
import { assert, constructURLBasedOnBaseURL, eventsHelper, monotonicTime } from '../utils'; import { assert, constructURLBasedOnBaseURL, createProxyAgent, eventsHelper, monotonicTime } from '../utils';
import { createGuid } from './utils/crypto'; import { createGuid } from './utils/crypto';
import { getUserAgent } from './utils/userAgent'; import { getUserAgent } from './utils/userAgent';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext'; import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
import { MultipartFormData } from './formData'; import { MultipartFormData } from './formData';
@ -183,8 +181,8 @@ export abstract class APIRequestContext extends SdkObject {
let agent; let agent;
// We skip 'per-context' in order to not break existing users. 'per-context' was previously used to // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
// workaround an upstream Chromium bug. Can be removed in the future. // workaround an upstream Chromium bug. Can be removed in the future.
if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) if (proxy?.server !== 'per-context')
agent = createProxyAgent(proxy); agent = createProxyAgent(proxy, requestUrl);
let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20); let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20);
maxRedirects = maxRedirects === 0 ? -1 : maxRedirects; maxRedirects = maxRedirects === 0 ? -1 : maxRedirects;
@ -647,13 +645,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
const timeoutSettings = new TimeoutSettings(); const timeoutSettings = new TimeoutSettings();
if (options.timeout !== undefined) if (options.timeout !== undefined)
timeoutSettings.setDefaultTimeout(options.timeout); timeoutSettings.setDefaultTimeout(options.timeout);
const proxy = options.proxy;
if (proxy?.server) {
let url = proxy?.server.trim();
if (!/^\w+:\/\//.test(url))
url = 'http://' + url;
proxy.server = url;
}
if (options.storageState) { if (options.storageState) {
this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin })); this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin }));
this._cookieStore.addCookies(options.storageState.cookies || []); this._cookieStore.addCookies(options.storageState.cookies || []);
@ -668,7 +659,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
maxRedirects: options.maxRedirects, maxRedirects: options.maxRedirects,
httpCredentials: options.httpCredentials, httpCredentials: options.httpCredentials,
clientCertificates: options.clientCertificates, clientCertificates: options.clientCertificates,
proxy, proxy: options.proxy,
timeoutSettings, timeoutSettings,
}; };
this._tracing = new Tracing(this, options.tracesDir); this._tracing = new Tracing(this, options.tracesDir);
@ -705,20 +696,6 @@ 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)
@ -791,19 +768,6 @@ function removeHeader(headers: { [name: string]: string }, name: string) {
delete headers[name]; delete headers[name];
} }
function shouldBypassProxy(url: URL, bypass?: string): boolean {
if (!bypass)
return false;
const domains = bypass.split(',').map(s => {
s = s.trim();
if (!s.startsWith('.'))
s = '.' + s;
return s;
});
const domain = '.' + url.hostname;
return domains.some(d => domain.endsWith(d));
}
function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) { function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) {
const { username, password } = credentials; const { username, password } = credentials;
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');

View File

@ -23,7 +23,7 @@ import tls from 'tls';
import { SocksProxy } from './utils/socksProxy'; import { SocksProxy } from './utils/socksProxy';
import { ManualPromise, escapeHTML, generateSelfSignedCertificate, rewriteErrorMessage } from '../utils'; import { ManualPromise, escapeHTML, generateSelfSignedCertificate, rewriteErrorMessage } from '../utils';
import { verifyClientCertificates } from './browserContext'; import { verifyClientCertificates } from './browserContext';
import { createProxyAgent } from './fetch'; import { createProxyAgent } from './utils/network';
import { debugLogger } from './utils/debugLogger'; import { debugLogger } from './utils/debugLogger';
import { createSocket, createTLSSocket } from './utils/happyEyeballs'; import { createSocket, createTLSSocket } from './utils/happyEyeballs';
@ -242,7 +242,7 @@ 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; proxyAgentFromOptions: ReturnType<typeof createProxyAgent>;
constructor( constructor(
contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'> contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'>
@ -250,7 +250,7 @@ export class ClientCertificatesProxy {
verifyClientCertificates(contextOptions.clientCertificates); 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.proxyAgentFromOptions = createProxyAgent(contextOptions.proxy);
this._initSecureContexts(contextOptions.clientCertificates); this._initSecureContexts(contextOptions.clientCertificates);
this._socksProxy = new SocksProxy(); this._socksProxy = new SocksProxy();
this._socksProxy.setPattern('*'); this._socksProxy.setPattern('*');

View File

@ -15,13 +15,13 @@
* limitations under the License. * limitations under the License.
*/ */
import { makeWaitForNextTask } from '../utils'; import { createProxyAgent, makeWaitForNextTask } from '../utils';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs';
import { ws } from '../utilsBundle'; import { ws } from '../utilsBundle';
import type { WebSocket } from '../utilsBundle'; import type { WebSocket } from '../utilsBundle';
import type { Progress } from './progress'; import type { Progress } from './progress';
import type { HeadersArray } from './types'; import type { HeadersArray, ProxySettings } from './types';
import type { ClientRequest, IncomingMessage } from 'http'; import type { ClientRequest, IncomingMessage } from 'http';
export const perMessageDeflate = { export const perMessageDeflate = {
@ -60,6 +60,13 @@ export interface ConnectionTransport {
onclose?: (reason?: string) => void, onclose?: (reason?: string) => void,
} }
type WebSocketTransportOptions = {
headers?: { [key: string]: string; };
followRedirects?: boolean;
debugLogHeader?: string;
proxy?: ProxySettings;
};
export class WebSocketTransport implements ConnectionTransport { export class WebSocketTransport implements ConnectionTransport {
private _ws: WebSocket; private _ws: WebSocket;
private _progress?: Progress; private _progress?: Progress;
@ -70,14 +77,14 @@ export class WebSocketTransport implements ConnectionTransport {
readonly wsEndpoint: string; readonly wsEndpoint: string;
readonly headers: HeadersArray = []; readonly headers: HeadersArray = [];
static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string): Promise<WebSocketTransport> { static async connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions = {}): Promise<WebSocketTransport> {
return await WebSocketTransport._connect(progress, url, headers || {}, { follow: !!followRedirects, hadRedirects: false }, debugLogHeader); return await WebSocketTransport._connect(progress, url, options, false /* hadRedirects */);
} }
static async _connect(progress: (Progress|undefined), url: string, headers: { [key: string]: string; }, redirect: { follow: boolean, hadRedirects: boolean }, debugLogHeader?: string): Promise<WebSocketTransport> { static async _connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions, hadRedirects: boolean): Promise<WebSocketTransport> {
const logUrl = stripQueryParams(url); const logUrl = stripQueryParams(url);
progress?.log(`<ws connecting> ${logUrl}`); progress?.log(`<ws connecting> ${logUrl}`);
const transport = new WebSocketTransport(progress, url, logUrl, headers, redirect.follow && redirect.hadRedirects, debugLogHeader); const transport = new WebSocketTransport(progress, url, logUrl, { ...options, followRedirects: !!options.followRedirects && hadRedirects });
let success = false; let success = false;
progress?.cleanupWhenAborted(async () => { progress?.cleanupWhenAborted(async () => {
if (!success) if (!success)
@ -94,13 +101,13 @@ export class WebSocketTransport implements ConnectionTransport {
transport._ws.close(); transport._ws.close();
}); });
transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => { transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => {
if (redirect.follow && !redirect.hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) { if (options.followRedirects && !hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) {
fulfill({ redirect: response }); fulfill({ redirect: response });
transport._ws.close(); transport._ws.close();
return; return;
} }
for (let i = 0; i < response.rawHeaders.length; i += 2) { for (let i = 0; i < response.rawHeaders.length; i += 2) {
if (debugLogHeader && response.rawHeaders[i] === debugLogHeader) if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader)
progress?.log(response.rawHeaders[i + 1]); progress?.log(response.rawHeaders[i + 1]);
} }
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@ -117,32 +124,34 @@ export class WebSocketTransport implements ConnectionTransport {
if (result.redirect) { if (result.redirect) {
// Strip authorization headers from the redirected request. // Strip authorization headers from the redirected request.
const newHeaders = Object.fromEntries(Object.entries(headers || {}).filter(([name]) => { const newHeaders = Object.fromEntries(Object.entries(options.headers || {}).filter(([name]) => {
return !name.includes('access-key') && name.toLowerCase() !== 'authorization'; return !name.includes('access-key') && name.toLowerCase() !== 'authorization';
})); }));
return WebSocketTransport._connect(progress, result.redirect.headers.location!, newHeaders, { follow: true, hadRedirects: true }, debugLogHeader); return WebSocketTransport._connect(progress, result.redirect.headers.location!, { ...options, headers: newHeaders }, true /* hadRedirects */);
} }
success = true; success = true;
return transport; return transport;
} }
constructor(progress: Progress|undefined, url: string, logUrl: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string) { constructor(progress: Progress|undefined, url: string, logUrl: string, options: WebSocketTransportOptions) {
this.wsEndpoint = url; this.wsEndpoint = url;
this._logUrl = logUrl; this._logUrl = logUrl;
const proxyAgent = createProxyAgent(options.proxy, new URL(url));
const happyEyeballsAgent = (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent;
this._ws = new ws(url, [], { this._ws = new ws(url, [], {
maxPayload: 256 * 1024 * 1024, // 256Mb, maxPayload: 256 * 1024 * 1024, // 256Mb,
// Prevent internal http client error when passing negative timeout. // Prevent internal http client error when passing negative timeout.
handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1), handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1),
headers, headers: options.headers,
followRedirects, followRedirects: options.followRedirects,
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, agent: proxyAgent || happyEyeballsAgent,
perMessageDeflate, perMessageDeflate,
}); });
this._ws.on('upgrade', response => { this._ws.on('upgrade', response => {
for (let i = 0; i < response.rawHeaders.length; i += 2) { for (let i = 0; i < response.rawHeaders.length; i += 2) {
this.headers.push({ name: response.rawHeaders[i], value: response.rawHeaders[i + 1] }); this.headers.push({ name: response.rawHeaders[i], value: response.rawHeaders[i + 1] });
if (debugLogHeader && response.rawHeaders[i] === debugLogHeader) if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader)
progress?.log(response.rawHeaders[i + 1]); progress?.log(response.rawHeaders[i + 1]);
} }
}); });

View File

@ -20,10 +20,11 @@ import http2 from 'http2';
import https from 'https'; import https from 'https';
import url from 'url'; import url from 'url';
import { HttpsProxyAgent, getProxyForUrl } from '../../utilsBundle'; import { HttpsProxyAgent, SocksProxyAgent, getProxyForUrl } from '../../utilsBundle';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs';
import type net from 'net'; import type net from 'net';
import type { ProxySettings } from '../types';
export type HTTPRequestParams = { export type HTTPRequestParams = {
url: string, url: string,
@ -32,6 +33,7 @@ export type HTTPRequestParams = {
data?: string | Buffer, data?: string | Buffer,
timeout?: number, timeout?: number,
rejectUnauthorized?: boolean, rejectUnauthorized?: boolean,
proxy?: ProxySettings,
}; };
export const NET_DEFAULT_TIMEOUT = 30_000; export const NET_DEFAULT_TIMEOUT = 30_000;
@ -49,22 +51,26 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco
const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT; const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT;
const proxyURL = getProxyForUrl(params.url); if (params.proxy) {
if (proxyURL) { options.agent = createProxyAgent(params.proxy, new URL(params.url));
const parsedProxyURL = url.parse(proxyURL); } else {
if (params.url.startsWith('http:')) { const proxyURL = getProxyForUrl(params.url);
options = { if (proxyURL) {
path: parsedUrl.href, const parsedProxyURL = url.parse(proxyURL);
host: parsedProxyURL.hostname, if (params.url.startsWith('http:')) {
port: parsedProxyURL.port, options = {
headers: options.headers, path: parsedUrl.href,
method: options.method host: parsedProxyURL.hostname,
}; port: parsedProxyURL.port,
} else { headers: options.headers,
(parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:'; method: options.method
};
} else {
(parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:';
options.agent = new HttpsProxyAgent(parsedProxyURL); options.agent = new HttpsProxyAgent(parsedProxyURL);
options.rejectUnauthorized = false; options.rejectUnauthorized = false;
}
} }
} }
@ -109,6 +115,47 @@ export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequ
}); });
} }
function shouldBypassProxy(url: URL, bypass?: string): boolean {
if (!bypass)
return false;
const domains = bypass.split(',').map(s => {
s = s.trim();
if (!s.startsWith('.'))
s = '.' + s;
return s;
});
const domain = '.' + url.hostname;
return domains.some(d => domain.endsWith(d));
}
export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) {
if (!proxy)
return;
if (forUrl && proxy.bypass && shouldBypassProxy(forUrl, proxy.bypass))
return;
// Browsers allow to specify proxy without a protocol, defaulting to http.
let proxyServer = proxy.server.trim();
if (!/^\w+:\/\//.test(proxyServer))
proxyServer = 'http://' + proxyServer;
const proxyOpts = url.parse(proxyServer);
if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) {
// Force CONNECT method for WebSockets.
return new HttpsProxyAgent(proxyOpts);
}
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyOpts);
}
export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server;
export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server;
export function createHttpServer(...args: any[]): http.Server { export function createHttpServer(...args: any[]): http.Server {

View File

@ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions {
*/ */
logger?: Logger; logger?: Logger;
/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;
/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;
/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;
/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};
/** /**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0. * on. Defaults to 0.
@ -21834,6 +21862,34 @@ export interface ConnectOptions {
*/ */
logger?: Logger; logger?: Logger;
/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;
/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;
/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;
/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};
/** /**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0. * on. Defaults to 0.

View File

@ -540,6 +540,12 @@ export type LocalUtilsConnectParams = {
exposeNetwork?: string, exposeNetwork?: string,
slowMo?: number, slowMo?: number,
timeout?: number, timeout?: number,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
socksProxyRedirectPortForTest?: number, socksProxyRedirectPortForTest?: number,
}; };
export type LocalUtilsConnectOptions = { export type LocalUtilsConnectOptions = {
@ -547,6 +553,12 @@ export type LocalUtilsConnectOptions = {
exposeNetwork?: string, exposeNetwork?: string,
slowMo?: number, slowMo?: number,
timeout?: number, timeout?: number,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
socksProxyRedirectPortForTest?: number, socksProxyRedirectPortForTest?: number,
}; };
export type LocalUtilsConnectResult = { export type LocalUtilsConnectResult = {
@ -1165,11 +1177,23 @@ export type BrowserTypeConnectOverCDPParams = {
headers?: NameValue[], headers?: NameValue[],
slowMo?: number, slowMo?: number,
timeout?: number, timeout?: number,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
}; };
export type BrowserTypeConnectOverCDPOptions = { export type BrowserTypeConnectOverCDPOptions = {
headers?: NameValue[], headers?: NameValue[],
slowMo?: number, slowMo?: number,
timeout?: number, timeout?: number,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
}; };
export type BrowserTypeConnectOverCDPResult = { export type BrowserTypeConnectOverCDPResult = {
browser: BrowserChannel, browser: BrowserChannel,

View File

@ -702,6 +702,13 @@ LocalUtils:
exposeNetwork: string? exposeNetwork: string?
slowMo: number? slowMo: number?
timeout: number? timeout: number?
proxy:
type: object?
properties:
server: string
bypass: string?
username: string?
password: string?
socksProxyRedirectPortForTest: number? socksProxyRedirectPortForTest: number?
returns: returns:
pipe: JsonPipe pipe: JsonPipe
@ -1010,6 +1017,13 @@ BrowserType:
items: NameValue items: NameValue
slowMo: number? slowMo: number?
timeout: number? timeout: number?
proxy:
type: object?
properties:
server: string
bypass: string?
username: string?
password: string?
returns: returns:
browser: Browser browser: Browser
defaultContext: BrowserContext? defaultContext: BrowserContext?

View File

@ -765,6 +765,24 @@ for (const kind of ['launchServer', 'run-server'] as const) {
await browser.close(); await browser.close();
}); });
test('should connect over http proxy', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33894' },
}, async ({ connect, startRemoteServer, proxyServer }) => {
const remoteServer = await startRemoteServer(kind);
const url = new URL(remoteServer.wsEndpoint());
proxyServer.forwardTo(+url.port, { allowConnectRequests: true });
const browser = await connect(`http://some.random.host.does.not.exist:1337`, {
proxy: { server: `localhost:${proxyServer.PORT}` },
});
const page = await browser.newPage();
expect(await page.evaluate('11 * 11')).toBe(121);
await browser.close();
// We should "CONNECT" twice:
// - to convert http url into ws url
// - actually connect to the ws endpoint
expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']);
});
test.describe('socks proxy', () => { test.describe('socks proxy', () => {
test.skip(({ mode }) => mode !== 'default'); test.skip(({ mode }) => mode !== 'default');
test.skip(kind === 'launchServer', 'not supported yet'); test.skip(kind === 'launchServer', 'not supported yet');

View File

@ -446,6 +446,32 @@ test('should be able to connect via localhost', async ({ browserType }, testInfo
} }
}); });
test('should be able to connect over http proxy', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35206' },
}, async ({ browserType, proxyServer }, testInfo) => {
const port = 9339 + testInfo.workerIndex;
const browserServer = await browserType.launch({
args: ['--remote-debugging-port=' + port]
});
proxyServer.forwardTo(port, { allowConnectRequests: true });
try {
const cdpBrowser = await browserType.connectOverCDP(`http://some.random.host.does.not.exist:1337`, {
proxy: { server: `localhost:${proxyServer.PORT}` },
});
const contexts = cdpBrowser.contexts();
expect(contexts.length).toBe(1);
const page = await contexts[0].newPage();
expect(await page.evaluate('11 * 11')).toBe(121);
await cdpBrowser.close();
// We should "CONNECT" twice:
// - to convert http url into ws url
// - actually connect to the ws endpoint
expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']);
} finally {
await browserServer.close();
}
});
test('emulate media should not be affected by second connectOverCDP', async ({ browserType }, testInfo) => { test('emulate media should not be affected by second connectOverCDP', async ({ browserType }, testInfo) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24109' }); test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24109' });
test.fixme(); test.fixme();

View File

@ -323,7 +323,7 @@ it('should use SOCKS proxy for websocket requests', async ({ browserType, server
await closeProxyServer(); await closeProxyServer();
}); });
it('should use http proxy for websocket requests', async ({ browserName, browserType, server, proxyServer }) => { it('should use http proxy for websocket requests', async ({ isWindows, browserName, browserType, server, proxyServer }) => {
proxyServer.forwardTo(server.PORT, { allowConnectRequests: true }); proxyServer.forwardTo(server.PORT, { allowConnectRequests: true });
const browser = await browserType.launch({ const browser = await browserType.launch({
proxy: { server: `localhost:${proxyServer.PORT}` } proxy: { server: `localhost:${proxyServer.PORT}` }
@ -350,7 +350,7 @@ it('should use http proxy for websocket requests', async ({ browserName, browser
// WebKit does not use CONNECT for websockets, but other browsers do. // WebKit does not use CONNECT for websockets, but other browsers do.
if (browserName === 'webkit') if (browserName === 'webkit')
expect(proxyServer.wsUrls).toContain('ws://fake-localhost-127-0-0-1.nip.io:1337/ws'); expect(proxyServer.wsUrls).toContain(isWindows ? '/ws' : 'ws://fake-localhost-127-0-0-1.nip.io:1337/ws');
else else
expect(proxyServer.connectHosts).toContain('fake-localhost-127-0-0-1.nip.io:1337'); expect(proxyServer.connectHosts).toContain('fake-localhost-127-0-0-1.nip.io:1337');