diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index f58c102e16..7793bdb010 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -35,6 +35,9 @@ import { BrowserContext } from './server/browserContext'; import { CRBrowser } from './server/chromium/crBrowser'; import { CDPSessionDispatcher } from './dispatchers/cdpSessionDispatcher'; import { PageDispatcher } from './dispatchers/pageDispatcher'; +import { BrowserServerPortForwardingServer } from './server/socksSocket'; +import { SocksSocketDispatcher } from './dispatchers/socksSocketDispatcher'; +import { SocksInterceptedSocketHandler } from './server/socksServer'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { private _playwright: Playwright; @@ -46,20 +49,24 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { } async launchServer(options: LaunchServerOptions = {}): Promise { + const portForwardingServer = new BrowserServerPortForwardingServer(this._playwright, !!options._acceptForwardedPorts); // 1. Pre-launch the browser const browser = await this._browserType.launch(internalCallMetadata(), { ...options, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs), env: options.env ? envObjectToArray(options.env) : undefined, + ...portForwardingServer.browserLaunchOptions(), }, toProtocolLogger(options.logger)); // 2. Start the server const delegate: PlaywrightServerDelegate = { path: '/' + createGuid(), - allowMultipleClients: true, - onClose: () => {}, - onConnect: this._onConnect.bind(this, browser), + allowMultipleClients: options._acceptForwardedPorts ? false : true, + onClose: () => { + portForwardingServer.stop(); + }, + onConnect: this._onConnect.bind(this, browser, portForwardingServer), }; const server = new PlaywrightServer(delegate); const wsEndpoint = await server.listen(options.port); @@ -78,7 +85,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { return browserServer; } - private _onConnect(browser: Browser, scope: DispatcherScope, forceDisconnect: () => void) { + private _onConnect(browser: Browser, portForwardingServer: BrowserServerPortForwardingServer, scope: DispatcherScope, forceDisconnect: () => void) { const selectors = new Selectors(); const selectorsDispatcher = new SelectorsDispatcher(scope, selectors); const browserDispatcher = new ConnectedBrowserDispatcher(scope, browser, selectors); @@ -86,8 +93,16 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { // Underlying browser did close for some reason - force disconnect the client. forceDisconnect(); }); - new PlaywrightDispatcher(scope, this._playwright, selectorsDispatcher, browserDispatcher); + const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, selectorsDispatcher, browserDispatcher, (ports: number[]) => { + portForwardingServer.enablePortForwarding(ports); + }); + const incomingSocksSocketHandler = (socket: SocksInterceptedSocketHandler) => { + playwrightDispatcher._dispatchEvent('incomingSocksSocket', { socket: new SocksSocketDispatcher(playwrightDispatcher._scope, socket) }); + }; + portForwardingServer.on('incomingSocksSocket', incomingSocksSocketHandler); + return () => { + portForwardingServer.off('incomingSocksSocket', incomingSocksSocketHandler); // Cleanup contexts upon disconnect. browserDispatcher.cleanupContexts().catch(e => {}); }; diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 1d17b9461f..4d5854cc52 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -137,6 +137,8 @@ export class BrowserType extends ChannelOwner { - const prematureCloseListener = (event: { reason: string }) => { - reject(new Error('Server disconnected: ' + event.reason)); + const prematureCloseListener = (event: { code: number, reason: string }) => { + reject(new Error(`WebSocket server disconnected (${event.code}) ${event.reason}`)); }; ws.addEventListener('close', prematureCloseListener); const playwright = await connection.waitForObjectWithKnownName('Playwright') as Playwright; @@ -182,6 +184,16 @@ export class BrowserType extends ChannelOwner { diff --git a/src/client/connection.ts b/src/client/connection.ts index a634f68af5..91028975da 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -36,8 +36,10 @@ import { debugLogger } from '../utils/debugLogger'; import { SelectorsOwner } from './selectors'; import { isUnderTest } from '../utils/utils'; import { Android, AndroidSocket, AndroidDevice } from './android'; +import { SocksSocket } from './socksSocket'; import { captureStackTrace } from '../utils/stackTrace'; import { Artifact } from './artifact'; +import { EventEmitter } from 'events'; class Root extends ChannelOwner { constructor(connection: Connection) { @@ -45,7 +47,7 @@ class Root extends ChannelOwner { } } -export class Connection { +export class Connection extends EventEmitter { readonly _objects = new Map(); private _waitingForObject = new Map(); onmessage = (message: object): void => {}; @@ -56,6 +58,7 @@ export class Connection { private _onClose?: () => void; constructor(onClose?: () => void) { + super(); this._rootObject = new Root(this); this._onClose = onClose; } @@ -135,6 +138,7 @@ export class Connection { for (const callback of this._callbacks.values()) callback.reject(new Error(errorMessage)); this._callbacks.clear(); + this.emit('disconnect'); } isDisconnected() { @@ -239,6 +243,9 @@ export class Connection { case 'Worker': result = new Worker(parent, type, guid, initializer); break; + case 'SocksSocket': + result = new SocksSocket(parent, type, guid, initializer); + break; default: throw new Error('Missing type ' + type); } diff --git a/src/client/playwright.ts b/src/client/playwright.ts index a2f0bcf9c1..6802d3a740 100644 --- a/src/client/playwright.ts +++ b/src/client/playwright.ts @@ -22,6 +22,7 @@ import { Electron } from './electron'; import { TimeoutError } from '../utils/errors'; import { Size } from './types'; import { Android } from './android'; +import { SocksSocket } from './socksSocket'; type DeviceDescriptor = { userAgent: string, @@ -59,6 +60,8 @@ export class Playwright extends ChannelOwner SocksSocket.from(socket)); } _cleanup() { diff --git a/src/client/socksSocket.ts b/src/client/socksSocket.ts new file mode 100644 index 0000000000..b10a638958 --- /dev/null +++ b/src/client/socksSocket.ts @@ -0,0 +1,66 @@ +/** + * 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 net from 'net'; + +import * as channels from '../protocol/channels'; +import { assert, isLocalIpAddress, isUnderTest } from '../utils/utils'; +import { ChannelOwner } from './channelOwner'; + +export class SocksSocket extends ChannelOwner { + private _socket: net.Socket; + static from(socket: channels.SocksSocketChannel): SocksSocket { + return (socket as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.SocksSocketInitializer) { + super(parent, type, guid, initializer); + this._connection.on('disconnect', () => this._socket.end()); + + if (isUnderTest() && process.env.PW_TEST_PROXY_TARGET) + this._initializer.dstPort = Number(process.env.PW_TEST_PROXY_TARGET); + assert(isLocalIpAddress(this._initializer.dstAddr)); + + this._socket = net.createConnection(this._initializer.dstPort, this._initializer.dstAddr); + this._socket.on('error', (err: Error) => this._channel.error({error: String(err)})); + this._socket.on('connect', () => { + this.connected().catch(() => {}); + this._socket.on('data', data => this.write(data).catch(() => {})); + }); + this._socket.on('close', () => { + this.end().catch(() => {}); + }); + + this._channel.on('data', ({ data }) => { + if (!this._socket.writable) + return; + this._socket.write(Buffer.from(data, 'base64')); + }); + this._channel.on('close', () => this._socket.end()); + } + + async write(data: Buffer): Promise { + await this._channel.write({ data: data.toString('base64') }); + } + + async end(): Promise { + await this._channel.end(); + } + + async connected(): Promise { + await this._channel.connected(); + } +} diff --git a/src/client/types.ts b/src/client/types.ts index a91ef287c1..cae78ea5a3 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -71,11 +71,13 @@ export type LaunchPersistentContextOptions = Omit implements channels.PlaywrightChannel { - constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel) { + private _portForwardingCallback: ((ports: number[]) => void) | undefined; + constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel, portForwardingCallback?: (ports: number[]) => void) { const descriptors = require('../server/deviceDescriptors') as types.Devices; const deviceDescriptors = Object.entries(descriptors) .map(([name, descriptor]) => ({ name, descriptor })); @@ -38,5 +40,11 @@ export class PlaywrightDispatcher extends Dispatcher { + assert(this._portForwardingCallback, 'Port forwarding is only supported when using connect()'); + this._portForwardingCallback(params.ports); } } diff --git a/src/dispatchers/socksSocketDispatcher.ts b/src/dispatchers/socksSocketDispatcher.ts new file mode 100644 index 0000000000..5997c3acd5 --- /dev/null +++ b/src/dispatchers/socksSocketDispatcher.ts @@ -0,0 +1,47 @@ +/** + * 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 { Dispatcher, DispatcherScope } from './dispatcher'; +import * as channels from '../protocol/channels'; +import { SocksInterceptedSocketHandler } from '../server/socksServer'; + +export class SocksSocketDispatcher extends Dispatcher implements channels.SocksSocketChannel { + constructor(scope: DispatcherScope, socket: SocksInterceptedSocketHandler) { + super(scope, socket, 'SocksSocket', { + dstAddr: socket.dstAddr, + dstPort: socket.dstPort + }, true); + socket.on('data', (data: Buffer) => this._dispatchEvent('data', { data: data.toString('base64') })); + socket.on('close', () => { + this._dispatchEvent('close'); + this._dispose(); + }); + } + async connected(): Promise { + this._object.connected(); + } + async error(params: channels.SocksSocketErrorParams): Promise { + this._object.error(params.error); + } + + async write(params: channels.SocksSocketWriteParams): Promise { + this._object.write(Buffer.from(params.data, 'base64')); + } + + async end(): Promise { + this._object.end(); + } +} diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index f41565db65..c21dd391d5 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -179,7 +179,19 @@ export type PlaywrightInitializer = { preLaunchedBrowser?: BrowserChannel, }; export interface PlaywrightChannel extends Channel { + on(event: 'incomingSocksSocket', callback: (params: PlaywrightIncomingSocksSocketEvent) => void): this; + enablePortForwarding(params: PlaywrightEnablePortForwardingParams, metadata?: Metadata): Promise; } +export type PlaywrightIncomingSocksSocketEvent = { + socket: SocksSocketChannel, +}; +export type PlaywrightEnablePortForwardingParams = { + ports: number[], +}; +export type PlaywrightEnablePortForwardingOptions = { + +}; +export type PlaywrightEnablePortForwardingResult = void; // ----------- Selectors ----------- export type SelectorsInitializer = {}; @@ -3113,3 +3125,41 @@ export type AndroidElementInfo = { scrollable: boolean, selected: boolean, }; + +// ----------- SocksSocket ----------- +export type SocksSocketInitializer = { + dstAddr: string, + dstPort: number, +}; +export interface SocksSocketChannel extends Channel { + on(event: 'data', callback: (params: SocksSocketDataEvent) => void): this; + on(event: 'close', callback: (params: SocksSocketCloseEvent) => void): this; + write(params: SocksSocketWriteParams, metadata?: Metadata): Promise; + error(params: SocksSocketErrorParams, metadata?: Metadata): Promise; + connected(params?: SocksSocketConnectedParams, metadata?: Metadata): Promise; + end(params?: SocksSocketEndParams, metadata?: Metadata): Promise; +} +export type SocksSocketDataEvent = { + data: Binary, +}; +export type SocksSocketCloseEvent = {}; +export type SocksSocketWriteParams = { + data: Binary, +}; +export type SocksSocketWriteOptions = { + +}; +export type SocksSocketWriteResult = void; +export type SocksSocketErrorParams = { + error: string, +}; +export type SocksSocketErrorOptions = { + +}; +export type SocksSocketErrorResult = void; +export type SocksSocketConnectedParams = {}; +export type SocksSocketConnectedOptions = {}; +export type SocksSocketConnectedResult = void; +export type SocksSocketEndParams = {}; +export type SocksSocketEndOptions = {}; +export type SocksSocketEndResult = void; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 19cfd16efd..067ff870af 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -377,6 +377,20 @@ Playwright: # Only present when connecting remotely via BrowserType.connect() method. preLaunchedBrowser: Browser? + commands: + + enablePortForwarding: + parameters: + ports: + type: array + items: number + + events: + + incomingSocksSocket: + parameters: + socket: SocksSocket + Selectors: type: interface @@ -2523,3 +2537,29 @@ AndroidElementInfo: longClickable: boolean scrollable: boolean selected: boolean + +SocksSocket: + type: interface + + initializer: + dstAddr: string + dstPort: number + + commands: + write: + parameters: + data: binary + + error: + parameters: + error: string + + connected: + + end: + + events: + data: + parameters: + data: binary + close: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index c34e3533b0..d606c5c7b3 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -147,6 +147,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { })), value: tOptional(tType('SerializedValue')), }); + scheme.PlaywrightEnablePortForwardingParams = tObject({ + ports: tArray(tNumber), + }); scheme.SelectorsRegisterParams = tObject({ name: tString, source: tString, @@ -1221,6 +1224,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scrollable: tBoolean, selected: tBoolean, }); + scheme.SocksSocketWriteParams = tObject({ + data: tBinary, + }); + scheme.SocksSocketErrorParams = tObject({ + error: tString, + }); + scheme.SocksSocketConnectedParams = tOptional(tObject({})); + scheme.SocksSocketEndParams = tOptional(tObject({})); return scheme; } diff --git a/src/server/chromium/chromium.ts b/src/server/chromium/chromium.ts index 3f516200bd..d590277ec4 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -34,6 +34,10 @@ import { CallMetadata } from '../instrumentation'; import { findChromiumChannel } from './findChromiumChannel'; import http from 'http'; +type LaunchServerOptions = { + _acceptForwardedPorts?: boolean, +}; + export class Chromium extends BrowserType { private _devtools: CRDevTools | undefined; @@ -119,7 +123,7 @@ export class Chromium extends BrowserType { transport.send(message); } - _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { + _defaultArgs(options: types.LaunchOptions & LaunchServerOptions, isPersistent: boolean, userDataDir: string): string[] { const { args = [], proxy } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) @@ -155,10 +159,14 @@ export class Chromium extends BrowserType { chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`); } chromeArguments.push(`--proxy-server=${proxy.server}`); - if (proxy.bypass) { - const patterns = proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t); - chromeArguments.push(`--proxy-bypass-list=${patterns.join(';')}`); - } + const proxyBypassRules = []; + // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 + if (options._acceptForwardedPorts) + proxyBypassRules.push('<-loopback>'); + if (proxy.bypass) + proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); + if (proxyBypassRules.length > 0) + chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); } chromeArguments.push(...args); if (isPersistent) diff --git a/src/server/socksServer.ts b/src/server/socksServer.ts new file mode 100644 index 0000000000..8ef128b25c --- /dev/null +++ b/src/server/socksServer.ts @@ -0,0 +1,300 @@ +/** + * 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 net from 'net'; + +import { assert } from '../utils/utils'; +import { SdkObject } from './instrumentation'; + +export type SocksConnectionInfo = { + srcAddr: string, + srcPort: number, + dstAddr: string; + dstPort: number; +}; + +enum ConnectionPhases { + VERSION = 0, + NMETHODS, + METHODS, + REQ_CMD, + REQ_RSV, + REQ_ATYP, + REQ_DSTADDR, + REQ_DSTADDR_VARLEN, + REQ_DSTPORT, + DONE, +} + +enum SOCKS_AUTH_METHOD { + NO_AUTH = 0 +} + +enum SOCKS_CMD { + CONNECT = 0x01, + BIND = 0x02, + UDP = 0x03 +} + +enum SOCKS_ATYP { + IPv4 = 0x01, + NAME = 0x03, + IPv6 = 0x04 +} + +enum SOCKS_REPLY { + SUCCESS = 0x00, +} + +const SOCKS_VERSION = 0x5; + +const BUF_REP_INTR_SUCCESS = Buffer.from([ + 0x05, + SOCKS_REPLY.SUCCESS, + 0x00, + 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00 +]); + + +/** + * https://tools.ietf.org/html/rfc1928 + */ +class SocksV5ServerParser { + private _dstAddrp: number = 0; + private _dstPort?: number; + private _socket: net.Socket; + private _readyResolve!: (value?: unknown) => void; + private _ready: Promise; + private _info: SocksConnectionInfo; + private _phase: ConnectionPhases = ConnectionPhases.VERSION; + private _authMethods?: Buffer; + private _dstAddr?: Buffer; + private _addressType: any; + private _methodsp: number = 0; + constructor(socket: net.Socket) { + this._socket = socket; + this._info = { srcAddr: socket.remoteAddress!, srcPort: socket.remotePort!, dstAddr: '', dstPort: 0 }; + this._ready = new Promise(resolve => this._readyResolve = resolve); + socket.on('data', this._onData.bind(this)); + socket.on('error', () => {}); + } + private _onData(chunk: Buffer) { + const socket = this._socket; + let i = 0; + const readByte = () => chunk[i++]; + while (i < chunk.length && this._phase !== ConnectionPhases.DONE) { + switch (this._phase) { + case ConnectionPhases.VERSION: + assert(readByte() === SOCKS_VERSION); + this._phase = ConnectionPhases.NMETHODS; + break; + + case ConnectionPhases.NMETHODS: + this._authMethods = Buffer.alloc(readByte()); + this._phase = ConnectionPhases.METHODS; + break; + + case ConnectionPhases.METHODS: { + assert(this._authMethods); + chunk.copy(this._authMethods, 0, i, i + chunk.length); + assert(this._authMethods.includes(SOCKS_AUTH_METHOD.NO_AUTH)); + const left = this._authMethods.length - this._methodsp; + const chunkLeft = chunk.length - i; + const minLen = (left < chunkLeft ? left : chunkLeft); + chunk.copy(this._authMethods, this._methodsp, i, i + minLen); + this._methodsp += minLen; + i += minLen; + assert(this._methodsp === this._authMethods.length); + if (i < chunk.length) + this._socket.unshift(chunk.slice(i)); + this._authWithoutPassword(socket); + this._phase = ConnectionPhases.REQ_CMD; + break; + } + + case ConnectionPhases.REQ_CMD: + assert(readByte() === SOCKS_VERSION); + const cmd: SOCKS_CMD = readByte(); + assert(cmd === SOCKS_CMD.CONNECT); + this._phase = ConnectionPhases.REQ_RSV; + break; + + case ConnectionPhases.REQ_RSV: + readByte(); + this._phase = ConnectionPhases.REQ_ATYP; + break; + + case ConnectionPhases.REQ_ATYP: + this._phase = ConnectionPhases.REQ_DSTADDR; + this._addressType = readByte(); + assert(this._addressType in SOCKS_ATYP); + if (this._addressType === SOCKS_ATYP.IPv4) + this._dstAddr = Buffer.alloc(4); + else if (this._addressType === SOCKS_ATYP.IPv6) + this._dstAddr = Buffer.alloc(16); + else if (this._addressType === SOCKS_ATYP.NAME) + this._phase = ConnectionPhases.REQ_DSTADDR_VARLEN; + break; + + case ConnectionPhases.REQ_DSTADDR: { + assert(this._dstAddr); + const left = this._dstAddr.length - this._dstAddrp; + const chunkLeft = chunk.length - i; + const minLen = (left < chunkLeft ? left : chunkLeft); + chunk.copy(this._dstAddr, this._dstAddrp, i, i + minLen); + this._dstAddrp += minLen; + i += minLen; + if (this._dstAddrp === this._dstAddr.length) + this._phase = ConnectionPhases.REQ_DSTPORT; + break; + } + + case ConnectionPhases.REQ_DSTADDR_VARLEN: + this._dstAddr = Buffer.alloc(readByte()); + this._phase = ConnectionPhases.REQ_DSTADDR; + break; + + case ConnectionPhases.REQ_DSTPORT: + assert(this._dstAddr); + if (this._dstPort === undefined) { + this._dstPort = readByte(); + break; + } + this._dstPort <<= 8; + this._dstPort += readByte(); + + this._socket.removeListener('data', this._onData); + if (i < chunk.length) + this._socket.unshift(chunk.slice(i)); + + if (this._addressType === SOCKS_ATYP.IPv4) { + this._info.dstAddr = [...this._dstAddr].join('.'); + } else if (this._addressType === SOCKS_ATYP.IPv6) { + let ipv6str = ''; + const addr = this._dstAddr; + for (let b = 0; b < 16; ++b) { + if (b % 2 === 0 && b > 0) + ipv6str += ':'; + ipv6str += (addr[b] < 16 ? '0' : '') + addr[b].toString(16); + } + this._info.dstAddr = ipv6str; + } else { + this._info.dstAddr = this._dstAddr.toString(); + } + this._info.dstPort = this._dstPort; + this._phase = ConnectionPhases.DONE; + this._readyResolve(); + return; + default: + assert(false); + } + } + } + + private _authWithoutPassword(socket: net.Socket) { + socket.write(Buffer.from([0x05, 0x00])); + } + + async ready(): Promise<{ info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler }> { + await this._ready; + return { + info: this._info, + forward: () => { + const dstSocket = new net.Socket(); + this._socket.on('close', () => dstSocket.end()); + this._socket.on('end', () => dstSocket.end()); + dstSocket.setKeepAlive(false); + dstSocket.on('error', (err: NodeJS.ErrnoException) => writeSocksSocketError(this._socket, String(err))); + dstSocket.on('connect', () => { + this._socket.write(BUF_REP_INTR_SUCCESS); + this._socket.pipe(dstSocket).pipe(this._socket); + this._socket.resume(); + }).connect(this._info.dstPort, this._info.dstAddr); + }, + intercept: (parent: SdkObject): SocksInterceptedSocketHandler => { + return new SocksInterceptedSocketHandler(parent, this._socket, this._info.dstAddr, this._info.dstPort); + }, + }; + } +} + +export class SocksInterceptedSocketHandler extends SdkObject { + socket: net.Socket; + dstAddr: string; + dstPort: number; + constructor(parent: SdkObject, socket: net.Socket, dstAddr: string, dstPort: number) { + super(parent, 'SocksSocket'); + this.socket = socket; + this.dstAddr = dstAddr; + this.dstPort = dstPort; + socket.on('data', data => this.emit('data', data)); + socket.on('close', data => this.emit('close', data)); + } + connected() { + this.socket.write(BUF_REP_INTR_SUCCESS); + this.socket.resume(); + } + error(error: string) { + this.socket.resume(); + writeSocksSocketError(this.socket, error); + } + write(data: Buffer) { + this.socket.write(data); + } + end() { + this.socket.end(); + } +} + +function writeSocksSocketError(socket: net.Socket, error: string) { + if (!socket.writable) + return; + socket.write(BUF_REP_INTR_SUCCESS); + + const body = `Connection error: ${error}`; + socket.end([ + 'HTTP/1.1 502 OK', + 'Connection: close', + 'Content-Type: text/plain', + 'Content-Length: ' + Buffer.byteLength(body), + '', + body + ].join('\r\n')); +} + +type IncomingProxyRequestHandler = (info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler) => void; + +export class SocksProxyServer { + public server: net.Server; + constructor(incomingMessageHandler: IncomingProxyRequestHandler) { + this.server = net.createServer(this._handleConnection.bind(this, incomingMessageHandler)); + } + + public listen(port: number, host?: string) { + this.server.listen(port, host); + } + + async _handleConnection(incomingMessageHandler: IncomingProxyRequestHandler, socket: net.Socket) { + const parser = new SocksV5ServerParser(socket); + const { info, forward, intercept } = await parser.ready(); + incomingMessageHandler(info, forward, intercept); + } + + public close() { + this.server.close(); + } +} diff --git a/src/server/socksSocket.ts b/src/server/socksSocket.ts new file mode 100644 index 0000000000..8f3b09e57a --- /dev/null +++ b/src/server/socksSocket.ts @@ -0,0 +1,83 @@ +/** + * 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 net from 'net'; +import { EventEmitter } from 'events'; + +import { SdkObject } from './instrumentation'; +import { debugLogger } from '../utils/debugLogger'; +import { isLocalIpAddress } from '../utils/utils'; +import { SocksProxyServer, SocksConnectionInfo, SocksInterceptedSocketHandler } from './socksServer'; +import { LaunchOptions } from './types'; + +export class BrowserServerPortForwardingServer extends EventEmitter { + enabled: boolean; + private _forwardPorts: number[] = []; + private _parent: SdkObject; + private _server: SocksProxyServer; + constructor(parent: SdkObject, enabled: boolean) { + super(); + this.setMaxListeners(0); + this.enabled = enabled; + this._parent = parent; + this._server = new SocksProxyServer(this._handler.bind(this)); + if (enabled) { + this._server.listen(0); + debugLogger.log('proxy', `initialized server on port ${this._port()})`); + } + } + + private _port(): number { + if (!this.enabled) + return 0; + return (this._server.server.address() as net.AddressInfo).port; + } + + public browserLaunchOptions(): LaunchOptions | undefined { + if (!this.enabled) + return; + return { + proxy: { + server: `socks5://127.0.0.1:${this._port()}` + } + }; + } + + private _handler(info: SocksConnectionInfo, forward: () => void, intercept: (parent: SdkObject) => SocksInterceptedSocketHandler): void { + const shouldProxyRequestToClient = isLocalIpAddress(info.dstAddr) && this._forwardPorts.includes(info.dstPort); + debugLogger.log('proxy', `incoming connection from ${info.srcAddr}:${info.srcPort} to ${info.dstAddr}:${info.dstPort} shouldProxyRequestToClient=${shouldProxyRequestToClient}`); + if (!shouldProxyRequestToClient) { + forward(); + return; + } + const socket = intercept(this._parent); + this.emit('incomingSocksSocket', socket); + } + + public enablePortForwarding(ports: number[]): void { + if (!this.enabled) + throw new Error(`Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.`); + debugLogger.log('proxy', `enable port forwarding on ports: ${ports}`); + this._forwardPorts = ports; + } + + public stop(): void { + if (!this.enabled) + return; + debugLogger.log('proxy', 'stopping server'); + this._server.close(); + } +} diff --git a/src/utils/debugLogger.ts b/src/utils/debugLogger.ts index da69d74a3c..754509fd84 100644 --- a/src/utils/debugLogger.ts +++ b/src/utils/debugLogger.ts @@ -22,6 +22,7 @@ const debugLoggerColorMap = { 'protocol': 34, // green 'install': 34, // green 'browser': 0, // reset + 'proxy': 92, // purple 'error': 160, // red, 'channel:command': 33, // blue 'channel:response': 202, // orange diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ed98d10c74..ebb60081f0 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -177,3 +177,7 @@ export function canAccessFile(file: string) { return false; } } + +export function isLocalIpAddress(ipAdress: string): boolean { + return ['localhost', '127.0.0.1', '::ffff:127.0.0.1', '::1'].includes(ipAdress); +} diff --git a/tests/portForwardingServer.spec.ts b/tests/portForwardingServer.spec.ts new file mode 100644 index 0000000000..5bfee8f295 --- /dev/null +++ b/tests/portForwardingServer.spec.ts @@ -0,0 +1,162 @@ +/** + * 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 http from 'http'; + +import { contextTest as it, expect } from './config/browserTest'; +import type { LaunchOptions, ConnectOptions } from '../index'; + +it.skip(({ mode }) => mode !== 'default'); + +let targetTestServer: http.Server; +let port!: number; +it.beforeAll(async ({}, test) => { + port = 30_000 + test.workerIndex * 4; + targetTestServer = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { + res.end('from-retargeted-server'); + }).listen(port); +}); + +it.beforeEach(() => { + delete process.env.PW_TEST_PROXY_TARGET; +}); + +it.afterAll(() => { + targetTestServer.close(); +}); + +it('should forward non-forwarded requests', async ({ browserType, browserOptions, server }, workerInfo) => { + process.env.PW_TEST_PROXY_TARGET = port.toString(); + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end('original-target'); + }); + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: true + } as LaunchOptions); + const browser = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [] + } as ConnectOptions); + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/foo.html'); + expect(await page.content()).toContain('original-target'); + expect(reachedOriginalTarget).toBe(true); + await browserServer.close(); +}); + +it('should proxy local requests', async ({ browserType, browserOptions, server }, workerInfo) => { + process.env.PW_TEST_PROXY_TARGET = port.toString(); + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end(''); + }); + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: true + } as LaunchOptions); + const browser = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [examplePort] + } as ConnectOptions); + const page = await browser.newPage(); + await page.goto(`http://localhost:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-retargeted-server'); + expect(reachedOriginalTarget).toBe(false); + await browserServer.close(); +}); + +it('should lead to the error page for forwarded requests when the connection is refused', async ({ browserType, browserOptions, browserName, isWindows}, workerInfo) => { + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: true + } as LaunchOptions); + const browser = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [examplePort] + } as ConnectOptions); + const page = await browser.newPage(); + const response = await page.goto(`http://localhost:${examplePort}`); + expect(response.status()).toBe(502); + await page.waitForSelector('text=Connection error'); + await browserServer.close(); +}); + +it('should lead to the error page for non-forwarded requests when the connection is refused', async ({ browserName, browserType, browserOptions, isWindows}, workerInfo) => { + process.env.PW_TEST_PROXY_TARGET = '50001'; + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: true + } as LaunchOptions); + const browser = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [] + } as ConnectOptions); + const page = await browser.newPage(); + const response = await page.goto(`http://localhost:44123/non-existing-url`); + expect(response.status()).toBe(502); + await page.waitForSelector('text=Connection error'); + + await browserServer.close(); +}); + +it('should not allow connecting a second client when _acceptForwardedPorts is used', async ({ browserType, browserOptions }, workerInfo) => { + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: true + } as LaunchOptions); + const examplePort = 20_000 + workerInfo.workerIndex * 3; + + const browser1 = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [examplePort] + } as ConnectOptions); + await expect(browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [examplePort] + } as ConnectOptions)).rejects.toThrowError('browserType.connect: WebSocket server disconnected (1005)'); + await browser1.close(); + const browser2 = await browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [examplePort] + } as ConnectOptions); + await browser2.close(); + + await browserServer.close(); +}); + +it('should should not allow to connect when the server does not allow port-forwarding', async ({ browserType, browserOptions }, workerInfo) => { + const browserServer = await browserType.launchServer({ + ...browserOptions, + _acceptForwardedPorts: false + } as LaunchOptions); + + await expect(browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [] + } as ConnectOptions)).rejects.toThrowError('browserType.connect: Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.'); + await expect(browserType.connect({ + wsEndpoint: browserServer.wsEndpoint(), + _forwardPorts: [1234] + } as ConnectOptions)).rejects.toThrowError('browserType.connect: Port forwarding needs to be enabled when launching the server via BrowserType.launchServer.'); + + await browserServer.close(); +});