diff --git a/packages/playwright-core/src/common/socksProxy.ts b/packages/playwright-core/src/common/socksProxy.ts index 013e6638e9..21ba58e5b9 100644 --- a/packages/playwright-core/src/common/socksProxy.ts +++ b/packages/playwright-core/src/common/socksProxy.ts @@ -285,6 +285,61 @@ function parseIP(address: string): number[] { return address.split('.', 4).map(t => +t); } +type PatternMatcher = (host: string, port: number) => boolean; + +function starMatchToRegex(pattern: string) { + const source = pattern.split('*').map(s => { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }).join('.*'); + return new RegExp('^' + source + '$'); +} + +// This follows "Proxy bypass rules" syntax without implicit and negative rules. +// https://source.chromium.org/chromium/chromium/src/+/main:net/docs/proxy.md;l=331 +export function parsePattern(pattern: string | undefined): PatternMatcher { + if (!pattern) + return () => false; + + const matchers: PatternMatcher[] = pattern.split(',').map(token => { + const match = token.match(/^(.*?)(?::(\d+))?$/); + if (!match) + throw new Error(`Unsupported token "${token}" in pattern "${pattern}"`); + const tokenPort = match[2] ? +match[2] : undefined; + const portMatches = (port: number) => tokenPort === undefined || tokenPort === port; + let tokenHost = match[1]; + + if (tokenHost === '') { + return (host, port) => { + if (!portMatches(port)) + return false; + return host === 'localhost' + || host.endsWith('.localhost') + || host === '127.0.0.1' + || host === '[::1]'; + }; + } + + if (tokenHost === '*') + return (host, port) => portMatches(port); + + if (net.isIPv4(tokenHost) || net.isIPv6(tokenHost)) + return (host, port) => host === tokenHost && portMatches(port); + + if (tokenHost[0] === '.') + tokenHost = '*' + tokenHost; + const tokenRegex = starMatchToRegex(tokenHost); + return (host, port) => { + if (!portMatches(port)) + return false; + if (net.isIPv4(host) || net.isIPv6(host)) + return false; + return !!host.match(tokenRegex); + }; + }); + return (host, port) => matchers.some(matcher => matcher(host, port)); +} + export class SocksProxy extends EventEmitter implements SocksConnectionClient { static Events = { SocksRequested: 'socksRequested', @@ -297,7 +352,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { private _sockets = new Set(); private _closed = false; private _port: number | undefined; - private _pattern: string | undefined; + private _patternMatcher: PatternMatcher = () => false; private _directSockets = new Map(); constructor() { @@ -318,11 +373,11 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { } setPattern(pattern: string | undefined) { - this._pattern = pattern; - } - - private _matchesPattern(request: SocksSocketRequestedPayload) { - return this._pattern === '*' || (this._pattern === 'localhost' && request.host === 'localhost'); + try { + this._patternMatcher = parsePattern(pattern); + } catch (e) { + this._patternMatcher = () => false; + } } private async _handleDirect(request: SocksSocketRequestedPayload) { @@ -374,7 +429,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { } onSocketRequested(payload: SocksSocketRequestedPayload) { - if (!this._matchesPattern(payload)) { + if (!this._patternMatcher(payload.host, payload.port)) { this._handleDirect(payload); return; } @@ -431,26 +486,22 @@ export class SocksProxyHandler extends EventEmitter { }; private _sockets = new Map(); - private _pattern: string | undefined; + private _patternMatcher: PatternMatcher = () => false; private _redirectPortForTest: number | undefined; constructor(pattern: string | undefined, redirectPortForTest?: number) { super(); - this._pattern = pattern; + this._patternMatcher = parsePattern(pattern); this._redirectPortForTest = redirectPortForTest; } - private _matchesPattern(host: string, port: number) { - return this._pattern === '*' || (this._pattern === 'localhost' && host === 'localhost'); - } - cleanup() { for (const uid of this._sockets.keys()) this.socketClosed({ uid }); } async socketRequested({ uid, host, port }: SocksSocketRequestedPayload): Promise { - if (!this._matchesPattern(host, port)) { + if (!this._patternMatcher(host, port)) { const payload: SocksSocketFailedPayload = { uid, errorCode: 'ECONNREFUSED' }; this.emit(SocksProxyHandler.Events.SocksFailed, payload); return; diff --git a/tests/library/proxy-pattern.spec.ts b/tests/library/proxy-pattern.spec.ts new file mode 100644 index 0000000000..c8a7a78a48 --- /dev/null +++ b/tests/library/proxy-pattern.spec.ts @@ -0,0 +1,117 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 { parsePattern } from '../../packages/playwright-core/lib/common/socksProxy'; +import { playwrightTest as test, expect } from '../config/browserTest'; + +test('socks proxy patter matcher', async ({}) => { + const m1 = parsePattern('*'); + expect.soft(m1('example.com', 80)).toBe(true); + expect.soft(m1('some.long.example.com', 80)).toBe(true); + expect.soft(m1('localhost', 3000)).toBe(true); + expect.soft(m1('foo.localhost', 3000)).toBe(true); + expect.soft(m1('127.0.0.1', 9222)).toBe(true); + expect.soft(m1('123.123.123.123', 9222)).toBe(true); + expect.soft(m1('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(true); + expect.soft(m1('[::1]', 5000)).toBe(true); + + const m2 = parsePattern(''); + expect.soft(m2('example.com', 80)).toBe(false); + expect.soft(m2('some.long.example.com', 80)).toBe(false); + expect.soft(m2('localhost', 3000)).toBe(true); + expect.soft(m2('foo.localhost', 3000)).toBe(true); + expect.soft(m2('127.0.0.1', 9222)).toBe(true); + expect.soft(m2('123.123.123.123', 9222)).toBe(false); + expect.soft(m2('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m2('[::1]', 5000)).toBe(true); + + const m3 = parsePattern(':3000'); + expect.soft(m3('example.com', 80)).toBe(false); + expect.soft(m3('some.long.example.com', 80)).toBe(false); + expect.soft(m3('localhost', 3000)).toBe(true); + expect.soft(m3('foo.localhost', 3000)).toBe(true); + expect.soft(m3('127.0.0.1', 9222)).toBe(false); + expect.soft(m3('123.123.123.123', 9222)).toBe(false); + expect.soft(m3('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m3('[::1]', 5000)).toBe(false); + + const m4 = parsePattern('.com:80'); + expect.soft(m4('example.com', 80)).toBe(true); + expect.soft(m4('some.long.example.com', 80)).toBe(true); + expect.soft(m4('localhost', 3000)).toBe(false); + expect.soft(m4('foo.localhost', 3000)).toBe(false); + expect.soft(m4('127.0.0.1', 9222)).toBe(false); + expect.soft(m4('123.123.123.123', 9222)).toBe(false); + expect.soft(m4('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m4('[::1]', 5000)).toBe(false); + + const m5 = parsePattern('example.com'); + expect.soft(m5('example.com', 80)).toBe(true); + expect.soft(m5('some.long.example.com', 80)).toBe(false); + expect.soft(m5('localhost', 3000)).toBe(false); + expect.soft(m5('foo.localhost', 3000)).toBe(false); + expect.soft(m5('127.0.0.1', 9222)).toBe(false); + expect.soft(m5('123.123.123.123', 9222)).toBe(false); + expect.soft(m5('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m5('[::1]', 5000)).toBe(false); + + const m6 = parsePattern('*.com'); + expect.soft(m6('example.com', 80)).toBe(true); + expect.soft(m6('some.long.example.com', 80)).toBe(true); + expect.soft(m6('localhost', 3000)).toBe(false); + expect.soft(m6('foo.localhost', 3000)).toBe(false); + expect.soft(m6('127.0.0.1', 9222)).toBe(false); + expect.soft(m6('123.123.123.123', 9222)).toBe(false); + expect.soft(m6('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m6('[::1]', 5000)).toBe(false); + + const m7 = parsePattern('123.123.123.123:9222'); + expect.soft(m7('example.com', 80)).toBe(false); + expect.soft(m7('some.long.example.com', 80)).toBe(false); + expect.soft(m7('localhost', 3000)).toBe(false); + expect.soft(m7('foo.localhost', 3000)).toBe(false); + expect.soft(m7('127.0.0.1', 9222)).toBe(false); + expect.soft(m7('123.123.123.123', 9222)).toBe(true); + expect.soft(m7('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m7('[::1]', 5000)).toBe(false); + + const m8 = parsePattern('example.com:80,localhost,*:9222'); + expect.soft(m8('example.com', 80)).toBe(true); + expect.soft(m8('some.long.example.com', 80)).toBe(false); + expect.soft(m8('localhost', 3000)).toBe(true); + expect.soft(m8('foo.localhost', 3000)).toBe(false); + expect.soft(m8('127.0.0.1', 9222)).toBe(true); + expect.soft(m8('123.123.123.123', 9222)).toBe(true); + expect.soft(m8('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m8('[::1]', 5000)).toBe(false); + + const m9 = parsePattern('127.*.*.1'); + expect.soft(m9('example.com', 80)).toBe(false); + expect.soft(m9('some.long.example.com', 80)).toBe(false); + expect.soft(m9('localhost', 3000)).toBe(false); + expect.soft(m9('foo.localhost', 3000)).toBe(false); + expect.soft(m9('127.0.0.1', 9222)).toBe(false); + expect.soft(m9('123.123.123.123', 9222)).toBe(false); + expect.soft(m9('[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]', 8080)).toBe(false); + expect.soft(m9('[::1]', 5000)).toBe(false); + + const m10 = parsePattern('foo?/bar.*.com'); + expect.soft(m10('foo?/bar.X.com', 80)).toBe(true); + expect.soft(m10('foo?/bar.Y.com', 80)).toBe(true); + expect.soft(m10('foo?/bar.com', 80)).toBe(false); + expect.soft(m10('fo/bar.X.com', 80)).toBe(false); + expect.soft(m10('fo?/bar.X.com', 80)).toBe(false); +});