Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(proxy): unify local network proxy behavior #10719

Merged
merged 9 commits into from Dec 10, 2021
2 changes: 2 additions & 0 deletions packages/playwright-core/src/server/chromium/chromium.ts
Expand Up @@ -277,6 +277,8 @@ export class Chromium extends BrowserType {
proxyBypassRules.push('<-loopback>');
if (proxy.bypass)
proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t));
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>'))
proxyBypassRules.push('<-loopback>');
if (proxyBypassRules.length > 0)
chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/playwright-core/src/server/chromium/crBrowser.ts
Expand Up @@ -90,10 +90,19 @@ export class CRBrowser extends Browser {

async newContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options);

let proxyBypassList = undefined;
if (options.proxy) {
if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK)
proxyBypassList = options.proxy.bypass;
else
proxyBypassList = '<-loopback>' + (options.proxy.bypass ? `,${options.proxy.bypass}` : '');
}

const { browserContextId } = await this._session.send('Target.createBrowserContext', {
disposeOnDetach: true,
proxyServer: options.proxy ? options.proxy.server : undefined,
proxyBypassList: options.proxy ? options.proxy.bypass : undefined,
proxyBypassList,
});
const context = new CRBrowserContext(this, browserContextId, options);
await context._initialize();
Expand Down
50 changes: 50 additions & 0 deletions tests/browsercontext-proxy.spec.ts
Expand Up @@ -91,6 +91,56 @@ it('should use proxy', async ({ contextFactory, server, proxyServer }) => {
await context.close();
});

it.describe('should proxy local network requests', () => {
for (const additionalBypass of [false, true]) {
it.describe(additionalBypass ? 'with other bypasses' : 'by default', () => {
for (const params of [
{
target: 'localhost',
description: 'localhost',
},
{
target: '127.0.0.1',
description: 'loopback address',
},
{
target: '169.254.3.4',
description: 'link-local'
}
]) {
it(`${params.description}`, async ({ platform, browserName, contextFactory, server, proxyServer }) => {
it.fail(browserName === 'webkit' && platform === 'darwin' && additionalBypass && ['localhost', '127.0.0.1'].includes(params.target), 'WK fails to proxy 127.0.0.1 and localhost if additional bypasses are present');

const path = `/target-${additionalBypass}-${params.target}.html`;
server.setRoute(path, async (req, res) => {
res.end('<html><title>Served by the proxy</title></html>');
});

const url = `http://${params.target}:${server.PORT}${path}`;
proxyServer.forwardTo(server.PORT);
const context = await contextFactory({
proxy: { server: `localhost:${proxyServer.PORT}`, bypass: additionalBypass ? '1.non.existent.domain.for.the.test' : undefined }
});

const page = await context.newPage();
await page.goto(url);
expect(proxyServer.requestUrls).toContain(url);
expect(await page.title()).toBe('Served by the proxy');

await page.goto('http://1.non.existent.domain.for.the.test/foo.html').catch(() => {});
if (additionalBypass)
expect(proxyServer.requestUrls).not.toContain('http://1.non.existent.domain.for.the.test/foo.html');
else
expect(proxyServer.requestUrls).toContain('http://1.non.existent.domain.for.the.test/foo.html');

await context.close();
});
}
});
}
});


it('should use ipv6 proxy', async ({ contextFactory, server, proxyServer, browserName }) => {
it.fail(browserName === 'firefox', 'page.goto: NS_ERROR_UNKNOWN_HOST');
it.fail(!!process.env.INSIDE_DOCKER, 'docker does not support IPv6 by default');
Expand Down
9 changes: 8 additions & 1 deletion tests/config/proxy.ts
Expand Up @@ -50,14 +50,21 @@ export class TestProxy {
await new Promise(x => this._server.close(x));
}

forwardTo(port: number) {
forwardTo(port: number, options?: { skipConnectRequests: boolean }) {
this._prependHandler('request', (req: IncomingMessage) => {
this.requestUrls.push(req.url);
const url = new URL(req.url);
url.host = `localhost:${port}`;
req.url = url.toString();
});
this._prependHandler('connect', (req: IncomingMessage) => {
// If using this proxy at the browser-level, you'll want to skip trying to
// MITM connect requests otherwise, unless the system/browser is configured
// to ignore HTTPS errors (or the host has been configured to trust the test
// certs), Playwright will crash in funny ways. (e.g. CR Headful tries to connect
// to accounts.google.com as part of its starup routine and fatally complains of "Invalid method encountered".)
if (options?.skipConnectRequests)
return;
this.connectHosts.push(req.url);
req.url = `localhost:${port}`;
});
Expand Down
49 changes: 49 additions & 0 deletions tests/proxy.spec.ts
Expand Up @@ -73,6 +73,55 @@ it('should work with IP:PORT notion', async ({ browserType, server }) => {
await browser.close();
});

it.describe('should proxy local network requests', () => {
for (const additionalBypass of [false, true]) {
it.describe(additionalBypass ? 'with other bypasses' : 'by default', () => {
for (const params of [
{
target: 'localhost',
description: 'localhost',
},
{
target: '127.0.0.1',
description: 'loopback address',
},
{
target: '169.254.3.4',
description: 'link-local'
}
]) {
it(`${params.description}`, async ({ platform, browserName, browserType, server, proxyServer }) => {
it.fail(browserName === 'webkit' && platform === 'darwin' && additionalBypass && ['localhost', '127.0.0.1'].includes(params.target), 'WK fails to proxy 127.0.0.1 and localhost if additional bypasses are present');

const path = `/target-${additionalBypass}-${params.target}.html`;
server.setRoute(path, async (req, res) => {
res.end('<html><title>Served by the proxy</title></html>');
});

const url = `http://${params.target}:${server.PORT}${path}`;
proxyServer.forwardTo(server.PORT, { skipConnectRequests: true });
const browser = await browserType.launch({
proxy: { server: `localhost:${proxyServer.PORT}`, bypass: additionalBypass ? '1.non.existent.domain.for.the.test' : undefined }
});

const page = await browser.newPage();
await page.goto(url);
expect(proxyServer.requestUrls).toContain(url);
expect(await page.title()).toBe('Served by the proxy');

await page.goto('http://1.non.existent.domain.for.the.test/foo.html').catch(() => {});
if (additionalBypass)
expect(proxyServer.requestUrls).not.toContain('http://1.non.existent.domain.for.the.test/foo.html');
else
expect(proxyServer.requestUrls).toContain('http://1.non.existent.domain.for.the.test/foo.html');

await browser.close();
});
}
});
}
});

it('should authenticate', async ({ browserType, server }) => {
server.setRoute('/target.html', async (req, res) => {
const auth = req.headers['proxy-authorization'];
Expand Down