diff --git a/docs/src/api/params.md b/docs/src/api/params.md index eae785f34737e..89fec3ebdd62f 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -259,8 +259,8 @@ Dangerous option; use with care. Defaults to `false`. 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. + - `username` ?<[string]> Optional username to use if HTTP or SOCKS5 proxy requires authentication. + - `password` ?<[string]> Optional password to use if HTTP or SOCKS5 proxy requires authentication. Network proxy settings. @@ -857,8 +857,8 @@ Actual picture of each page will be scaled down if necessary to fit the specifie - `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 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. + - `username` ?<[string]> Optional username to use if HTTP or SOCKS5 proxy requires authentication. + - `password` ?<[string]> Optional password to use if HTTP or SOCKS5 proxy requires authentication. Network proxy settings to use with this context. Defaults to none. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 8ac56c53a3ead..26e56a70df25e 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -10368,12 +10368,12 @@ export interface Browser { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -15769,12 +15769,12 @@ export interface BrowserType { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -16107,12 +16107,12 @@ export interface BrowserType { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -17852,12 +17852,12 @@ export interface APIRequest { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -22739,12 +22739,12 @@ export interface AndroidDevice { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -23502,12 +23502,12 @@ export interface LaunchOptions { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -23919,12 +23919,12 @@ export interface BrowserContextOptions { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 0722c511ee41a..abca131d15f92 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -23,6 +23,7 @@ import { Download } from './download'; import { SdkObject } from './instrumentation'; import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import { SocksUpstreamAuthProxy, needsSocksAuthInterception } from './socksProxyAuthInterceptor'; import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; import { BrowserInfo, serverRegistry } from '../serverRegistry'; @@ -80,6 +81,7 @@ export abstract class Browser extends SdkObject { private _contextForReuse: { context: BrowserContext, hash: string } | undefined; _closeReason: string | undefined; _isCollocatedWithServer: boolean = true; + _socksAuthProxy: SocksUpstreamAuthProxy | undefined; private _server: BrowserServer; constructor(parent: SdkObject, options: BrowserOptions) { @@ -103,6 +105,7 @@ export abstract class Browser extends SdkObject { async newContext(progress: Progress, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined; + let socksAuthProxy: SocksUpstreamAuthProxy | undefined; let context: BrowserContext | undefined; try { if (options.clientCertificates?.length) { @@ -110,9 +113,16 @@ export abstract class Browser extends SdkObject { options = { ...options }; options.proxyOverride = clientCertificatesProxy.proxySettings(); options.internalIgnoreHTTPSErrors = true; + } else if (needsSocksAuthInterception(options.proxy)) { + // Browsers do not support RFC 1929; we front the authenticated upstream with a local + // unauthenticated SOCKS5 server and forward connections through. + socksAuthProxy = await SocksUpstreamAuthProxy.create(progress, options.proxy!); + options = { ...options }; + options.proxyOverride = socksAuthProxy.proxySettings(); } context = await progress.race(this.doCreateNewContext(options)); context._clientCertificatesProxy = clientCertificatesProxy; + context._socksAuthProxy = socksAuthProxy; if ((options as any).__testHookBeforeSetStorageState) await progress.race((options as any).__testHookBeforeSetStorageState()); await context.setStorageState(progress, options.storageState, 'initial'); @@ -121,6 +131,7 @@ export abstract class Browser extends SdkObject { } catch (error) { await context?.close(progress, { reason: 'Failed to create context' }).catch(() => {}); await clientCertificatesProxy?.close().catch(() => {}); + await socksAuthProxy?.close().catch(() => {}); throw error; } } @@ -174,6 +185,7 @@ export abstract class Browser extends SdkObject { context.browserClosed(); if (this._defaultContext) this._defaultContext.browserClosed(); + this._socksAuthProxy?.close().catch(() => {}); this.stopServer(nullProgress).catch(() => {}); this.emit(Browser.Events.Disconnected); this.instrumentation.onBrowserClose(this); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index cf687c56836a2..4f554cfa1796e 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -41,6 +41,7 @@ import type * as frames from './frames'; import type { PageError } from './page'; import type { Progress } from './progress'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import type { SocksUpstreamAuthProxy } from './socksProxyAuthInterceptor'; import type { SerializedStorage } from '@injected/storageScript'; import type * as types from './types'; import type * as channels from '@protocol/channels'; @@ -111,6 +112,7 @@ export abstract class BrowserContext extends Sdk _closeReason: string | undefined; readonly clock: Clock; _clientCertificatesProxy: ClientCertificatesProxy | undefined; + _socksAuthProxy: SocksUpstreamAuthProxy | undefined; private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; @@ -259,6 +261,7 @@ export abstract class BrowserContext extends Sdk return; } this._clientCertificatesProxy?.close().catch(() => {}); + this._socksAuthProxy?.close().catch(() => {}); this.tracing.abort(); if (this._isPersistentContext) this.onClosePersistent(); @@ -785,8 +788,6 @@ export function normalizeProxySettings(proxy: types.ProxySettings): types.ProxyS } if (url.protocol === 'socks4:' && (proxy.username || proxy.password)) throw new Error(`Socks4 proxy protocol does not support authentication`); - if (url.protocol === 'socks5:' && (proxy.username || proxy.password)) - throw new Error(`Browser does not support socks5 proxy authentication`); server = url.protocol + '//' + url.host; if (bypass) bypass = bypass.split(',').map(t => t.trim()).join(','); diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 8096c9eedab47..3f513f6aa3dad 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -32,6 +32,7 @@ import { PipeTransport } from './pipeTransport'; import { isProtocolError } from './protocolError'; import { registry } from './registry'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import { SocksUpstreamAuthProxy, needsSocksAuthInterception } from './socksProxyAuthInterceptor'; import { WebSocketTransport } from './transport'; import type { Browser, BrowserOptions, BrowserProcess } from './browser'; @@ -69,25 +70,44 @@ export abstract class BrowserType extends SdkObject { const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; if (seleniumHubUrl) return this.launchWithSeleniumHub(progress, seleniumHubUrl, options); - return this._innerLaunchWithRetries(progress, options, undefined, helper.debugProtocolLogger(protocolLogger)).catch(e => { throw this._rewriteStartupLog(e); }); + let socksAuthProxy: SocksUpstreamAuthProxy | undefined; + if (needsSocksAuthInterception(options.proxy)) { + socksAuthProxy = await SocksUpstreamAuthProxy.create(progress, options.proxy!); + options = { ...options }; + options.proxyOverride = socksAuthProxy.proxySettings(); + } + try { + const browser = await this._innerLaunchWithRetries(progress, options, undefined, helper.debugProtocolLogger(protocolLogger)).catch(e => { throw this._rewriteStartupLog(e); }); + browser._socksAuthProxy = socksAuthProxy; + return browser; + } catch (error) { + await socksAuthProxy?.close().catch(() => {}); + throw error; + } } async launchPersistentContext(progress: Progress, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { internalIgnoreHTTPSErrors?: boolean, socksProxyPort?: number }): Promise { const launchOptions = this._validateLaunchOptions(options); // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. let clientCertificatesProxy: ClientCertificatesProxy | undefined; + let socksAuthProxy: SocksUpstreamAuthProxy | undefined; if (options.clientCertificates?.length) { clientCertificatesProxy = await ClientCertificatesProxy.create(progress, options); launchOptions.proxyOverride = clientCertificatesProxy.proxySettings(); options = { ...options }; options.internalIgnoreHTTPSErrors = true; + } else if (needsSocksAuthInterception(options.proxy)) { + socksAuthProxy = await SocksUpstreamAuthProxy.create(progress, options.proxy!); + launchOptions.proxyOverride = socksAuthProxy.proxySettings(); } try { const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; + browser._defaultContext!._socksAuthProxy = socksAuthProxy; return browser._defaultContext!; } catch (error) { await clientCertificatesProxy?.close().catch(() => {}); + await socksAuthProxy?.close().catch(() => {}); throw error; } } diff --git a/packages/playwright-core/src/server/socksProxyAuthInterceptor.ts b/packages/playwright-core/src/server/socksProxyAuthInterceptor.ts new file mode 100644 index 0000000000000..6647139f27d14 --- /dev/null +++ b/packages/playwright-core/src/server/socksProxyAuthInterceptor.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from 'events'; + +import { debugLogger } from '@utils/debugLogger'; +import { createProxyAgent } from '@utils/network'; +import { SocksProxy } from '@utils/socksProxy'; + +import type net from 'net'; +import type { Progress } from './progress'; +import type * as types from './types'; +import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '@utils/socksProxy'; + +// SOCKS5 with username/password authentication is not supported natively by Chromium or Firefox. +// We work around this by running a local unauthenticated SOCKS5 server that the browser connects to, +// and forwarding each connection through to the user's upstream SOCKS5 proxy with RFC 1929 credentials. +// BROWSER LOCAL PROXY (no auth) UPSTREAM SOCKS5 (with auth) +// │ SOCKS5 Connect │ │ +// │────────────────────────►│ SOCKS5 + RFC 1929 auth │ +// │ │─────────────────────────────►│ +// │ │ │ TCP Connect +// │ │ │────────────►target +// │◄════════════════════════│══════════════════════════════│════════════│ + +class UpstreamConnection { + private readonly _interceptor: SocksUpstreamAuthProxy; + private readonly _uid: string; + private readonly _targetHost: string; + private readonly _targetPort: number; + private _serverSocket: net.Socket | undefined; + private _closed = false; + + constructor(interceptor: SocksUpstreamAuthProxy, uid: string, host: string, port: number) { + this._interceptor = interceptor; + this._uid = uid; + this._targetHost = host; + this._targetPort = port; + } + + async connect() { + const agent = this._interceptor._upstreamAgent(); + // SocksProxyAgent.connect returns a net.Socket whose far end is past the authenticated + // SOCKS5 handshake. We can then pipe raw bytes through it. + this._serverSocket = await agent.connect(new EventEmitter() as any, { host: this._targetHost, port: this._targetPort, secureEndpoint: false }) as net.Socket; + if (this._closed) { + this._serverSocket.destroy(); + return; + } + const socks = this._interceptor._socks; + this._serverSocket.on('data', data => socks.sendSocketData({ uid: this._uid, data })); + this._serverSocket.on('end', () => socks.sendSocketEnd({ uid: this._uid })); + this._serverSocket.on('error', error => socks.sendSocketError({ uid: this._uid, error: error.message })); + socks.socketConnected({ + uid: this._uid, + host: this._serverSocket.localAddress || '127.0.0.1', + port: this._serverSocket.localPort || 0, + }); + } + + onBrowserData(data: Buffer) { + this._serverSocket?.write(data); + } + + close() { + this._closed = true; + this._serverSocket?.destroy(); + } +} + +export class SocksUpstreamAuthProxy { + readonly _socks: SocksProxy; + private readonly _upstream: types.ProxySettings; + private readonly _connections = new Map(); + + private constructor(upstream: types.ProxySettings) { + this._upstream = upstream; + this._socks = new SocksProxy(); + this._socks.setPattern('*'); + this._socks.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { + const connection = new UpstreamConnection(this, payload.uid, payload.host, payload.port); + try { + this._connections.set(payload.uid, connection); + await connection.connect(); + } catch (error) { + debugLogger.log('socks', `Upstream SOCKS5 connection to ${payload.host}:${payload.port} failed: ${error.message}`); + this._connections.delete(payload.uid); + this._socks.socketFailed({ uid: payload.uid, errorCode: error.code || 'ECONNREFUSED' }); + } + }); + this._socks.addListener(SocksProxy.Events.SocksData, (payload: SocksSocketDataPayload) => { + this._connections.get(payload.uid)?.onBrowserData(payload.data); + }); + this._socks.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => { + this._connections.get(payload.uid)?.close(); + this._connections.delete(payload.uid); + }); + } + + static async create(progress: Progress, upstream: types.ProxySettings): Promise { + const proxy = new SocksUpstreamAuthProxy(upstream); + try { + await progress.race(proxy._socks.listen(0, '127.0.0.1')); + return proxy; + } catch (error) { + await progress.race(proxy.close().catch(() => {})); + throw error; + } + } + + proxySettings(): types.ProxySettings { + return { server: `socks5://127.0.0.1:${this._socks.port()}`, bypass: this._upstream.bypass }; + } + + _upstreamAgent() { + // createProxyAgent inlines username/password into the URL and rewrites socks5: -> socks5h: + // so hostnames are resolved by the upstream proxy. + return createProxyAgent(this._upstream)!; + } + + async close() { + await this._socks.close(); + } +} + +export function needsSocksAuthInterception(proxy: types.ProxySettings | undefined): boolean { + if (!proxy || (!proxy.username && !proxy.password)) + return false; + try { + return new URL(proxy.server).protocol === 'socks5:'; + } catch { + return false; + } +} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 8ac56c53a3ead..26e56a70df25e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10368,12 +10368,12 @@ export interface Browser { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -15769,12 +15769,12 @@ export interface BrowserType { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -16107,12 +16107,12 @@ export interface BrowserType { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -17852,12 +17852,12 @@ export interface APIRequest { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -22739,12 +22739,12 @@ export interface AndroidDevice { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -23502,12 +23502,12 @@ export interface LaunchOptions { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; @@ -23919,12 +23919,12 @@ export interface BrowserContextOptions { bypass?: string; /** - * Optional username to use if HTTP proxy requires authentication. + * Optional username to use if HTTP or SOCKS5 proxy requires authentication. */ username?: string; /** - * Optional password to use if HTTP proxy requires authentication. + * Optional password to use if HTTP or SOCKS5 proxy requires authentication. */ password?: string; }; diff --git a/packages/utils/network.ts b/packages/utils/network.ts index de32b45d7f840..8c4a051fd2feb 100644 --- a/packages/utils/network.ts +++ b/packages/utils/network.ts @@ -127,6 +127,10 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { return; const proxyURL = normalizeProxyURL(proxy.server); + if (proxy.username) { + proxyURL.username = encodeURIComponent(proxy.username); + proxyURL.password = encodeURIComponent(proxy.password || ''); + } if (proxyURL.protocol?.startsWith('socks')) { // SocksProxyAgent distinguishes between socks5 and socks5h. // socks5h is what we want, it means that hostnames are resolved by the proxy. @@ -138,10 +142,6 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { return new SocksProxyAgent(proxyURL); } - if (proxy.username) { - proxyURL.username = proxy.username; - proxyURL.password = proxy.password || ''; - } if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) { // Force CONNECT method for WebSockets. diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 697f39f956dff..80da2e67f4d0a 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -139,6 +139,138 @@ export class TestProxy { } } +export async function setupAuthSocksForwardingServer({ + port, forwardPort, allowedTargetPort, username, password, +}: { + port: number, forwardPort: number, allowedTargetPort: number, username: string, password: string, +}) { + // Hand-rolled SOCKS5 server (RFC 1928) that requires RFC 1929 username/password auth. + // Used only by tests; the production code path runs a separate local server in front of an authenticated upstream. + const authAttempts: { username: string, password: string }[] = []; + const sockets = new Set(); + + const server = net.createServer(socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + + let buffer = Buffer.alloc(0); + let state: 'greeting' | 'auth' | 'request' | 'tunnel' = 'greeting'; + let target: net.Socket | undefined; + + const tryRead = () => { + while (true) { + if (state === 'greeting') { + if (buffer.length < 2) + return; + const ver = buffer[0]; + const nMethods = buffer[1]; + if (buffer.length < 2 + nMethods) + return; + const methods = buffer.subarray(2, 2 + nMethods); + buffer = buffer.subarray(2 + nMethods); + if (ver !== 0x05 || !methods.includes(0x02)) { + socket.end(Buffer.from([0x05, 0xff])); // No acceptable methods. + return; + } + socket.write(Buffer.from([0x05, 0x02])); // Choose USERNAME/PASSWORD. + state = 'auth'; + } else if (state === 'auth') { + if (buffer.length < 2) + return; + const ulen = buffer[1]; + if (buffer.length < 2 + ulen + 1) + return; + const plen = buffer[2 + ulen]; + if (buffer.length < 2 + ulen + 1 + plen) + return; + const u = buffer.subarray(2, 2 + ulen).toString(); + const p = buffer.subarray(3 + ulen, 3 + ulen + plen).toString(); + buffer = buffer.subarray(3 + ulen + plen); + authAttempts.push({ username: u, password: p }); + if (u !== username || p !== password) { + socket.end(Buffer.from([0x01, 0x01])); // Auth failure. + return; + } + socket.write(Buffer.from([0x01, 0x00])); // Auth success. + state = 'request'; + } else if (state === 'request') { + if (buffer.length < 4) + return; + const cmd = buffer[1]; + const atyp = buffer[3]; + let addrLen: number; + if (atyp === 0x01) { + addrLen = 4; + } else if (atyp === 0x03) { + if (buffer.length < 5) + return; + addrLen = buffer[4] + 1; + } else if (atyp === 0x04) { + addrLen = 16; + } else { + socket.end(); + return; + } + if (buffer.length < 4 + addrLen + 2) + return; + let host: string; + if (atyp === 0x01) + host = Array.from(buffer.subarray(4, 8)).join('.'); + else if (atyp === 0x03) + host = buffer.subarray(5, 5 + buffer[4]).toString(); + else + host = 'ipv6'; + const portStart = atyp === 0x03 ? 5 + buffer[4] : 4 + addrLen; + const targetPort = buffer.readUInt16BE(portStart); + buffer = buffer.subarray(portStart + 2); + if (cmd !== 0x01) { + socket.end(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + return; + } + if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(host) || targetPort !== allowedTargetPort) { + socket.end(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); // Connection refused. + return; + } + target = new net.Socket(); + target.on('error', () => socket.destroy()); + target.connect(forwardPort, '127.0.0.1', () => { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); // Success. + state = 'tunnel'; + target!.pipe(socket); + socket.pipe(target!); + if (buffer.length) { + target!.write(buffer); + buffer = Buffer.alloc(0); + } + }); + return; + } else { + return; + } + } + }; + + socket.on('data', data => { + if (state === 'tunnel') + return; // pipes handle data + buffer = Buffer.concat([buffer, data]); + tryRead(); + }); + }); + + await new Promise(resolve => server.listen(port, '127.0.0.1', resolve)); + return { + closeProxyServer: async () => { + for (const s of sockets) + s.destroy(); + await new Promise(resolve => server.close(() => resolve())); + }, + proxyServerAddr: `socks5://127.0.0.1:${port}`, + authAttempts, + }; +} + export async function setupSocksForwardingServer({ port, forwardPort, allowedTargetPort }: { diff --git a/tests/library/browsercontext-proxy.spec.ts b/tests/library/browsercontext-proxy.spec.ts index 609f32b6c2a04..2e6b034dcdb9c 100644 --- a/tests/library/browsercontext-proxy.spec.ts +++ b/tests/library/browsercontext-proxy.spec.ts @@ -15,6 +15,7 @@ */ import { browserTest as it, expect } from '../config/browserTest'; +import { setupAuthSocksForwardingServer } from '../config/proxy'; it.skip(({ mode }) => mode.startsWith('service')); @@ -248,11 +249,47 @@ it('should work with IP:PORT notion', async ({ contextFactory, server, proxyServ await context.close(); }); -it('should throw for socks5 authentication', async ({ contextFactory }) => { - const error = await contextFactory({ - proxy: { server: `socks5://localhost:1234`, username: 'user', password: 'secret' } - }).catch(e => e); - expect(error.message).toContain('Browser does not support socks5 proxy authentication'); +it('should authenticate with socks5 proxy', async ({ contextFactory, server }) => { + const { proxyServerAddr, closeProxyServer, authAttempts } = await setupAuthSocksForwardingServer({ + port: it.info().workerIndex + 2048 + 20, + forwardPort: server.PORT, + allowedTargetPort: 1337, + username: 'user', + password: 'secret', + }); + try { + const context = await contextFactory({ + proxy: { server: proxyServerAddr, username: 'user', password: 'secret' } + }); + const page = await context.newPage(); + await page.goto('http://fake-localhost-127-0-0-1.nip.io:1337/target.html'); + expect(await page.title()).toBe('Served by the proxy'); + expect(authAttempts).toContainEqual({ username: 'user', password: 'secret' }); + await context.close(); + } finally { + await closeProxyServer(); + } +}); + +it('should fail with wrong socks5 credentials', async ({ contextFactory, server, browserName }) => { + const { proxyServerAddr, closeProxyServer } = await setupAuthSocksForwardingServer({ + port: it.info().workerIndex + 2048 + 21, + forwardPort: server.PORT, + allowedTargetPort: 1337, + username: 'user', + password: 'secret', + }); + try { + const context = await contextFactory({ + proxy: { server: proxyServerAddr, username: 'user', password: 'WRONG' } + }); + const page = await context.newPage(); + const error = await page.goto('http://fake-localhost-127-0-0-1.nip.io:1337/target.html').catch(e => e); + expect(error).toBeInstanceOf(Error); + await context.close(); + } finally { + await closeProxyServer(); + } }); it('should throw for socks4 authentication', async ({ contextFactory }) => { diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 031cc71c7defb..a5ec6934a6c4e 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { setupSocksForwardingServer } from '../config/proxy'; +import { setupAuthSocksForwardingServer, setupSocksForwardingServer } from '../config/proxy'; import { playwrightTest as it, expect } from '../config/browserTest'; import net from 'net'; @@ -330,6 +330,31 @@ it('should use proxy with emulated user agent', async ({ browserType }) => { }); +it('should authenticate with SOCKS5 proxy at launch', async ({ browserType, server }) => { + const { proxyServerAddr, closeProxyServer, authAttempts } = await setupAuthSocksForwardingServer({ + port: it.info().workerIndex + 2048 + 4, + forwardPort: server.PORT, + allowedTargetPort: 1337, + username: 'user', + password: 'secret', + }); + server.setRoute('/target.html', async (req, res) => { + res.end('Served by the proxy'); + }); + const browser = await browserType.launch({ + proxy: { server: proxyServerAddr, username: 'user', password: 'secret' } + }); + try { + const page = await browser.newPage(); + await page.goto('http://fake-localhost-127-0-0-1.nip.io:1337/target.html'); + expect(await page.title()).toBe('Served by the proxy'); + expect(authAttempts).toContainEqual({ username: 'user', password: 'secret' }); + } finally { + await browser.close(); + await closeProxyServer(); + } +}); + it('should use SOCKS proxy for websocket requests', async ({ browserType, server }) => { const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer({ port: it.info().workerIndex + 2048 + 2,