From 7c8bd00eb92c3613f0bc2ca2f44c21c1362d3507 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Fri, 7 May 2021 15:51:31 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20client=20proxy=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move request util into own module - Create custom ProxyHttpsAgent which handles proxying HTTPs requests - Does not support proxying HTTP requests since the client only communicates with an HTTPs API URL - Test proxy support using a simple man-in-the-middle proxy (shares SSL certs with test server) --- packages/client/src/client.js | 15 +- packages/client/src/request.js | 174 +++++++++++++++++++++ packages/client/src/utils.js | 109 +++++-------- packages/client/test/certs/test.crt | 17 +++ packages/client/test/certs/test.key | 28 ++++ packages/client/test/client.test.js | 15 -- packages/client/test/proxy.test.js | 229 ++++++++++++++++++++++++++++ 7 files changed, 488 insertions(+), 99 deletions(-) create mode 100644 packages/client/src/request.js create mode 100644 packages/client/test/certs/test.crt create mode 100644 packages/client/test/certs/test.key create mode 100644 packages/client/test/proxy.test.js diff --git a/packages/client/src/client.js b/packages/client/src/client.js index f4772191e..aec194a72 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -2,13 +2,8 @@ import PercyEnvironment from '@percy/env'; import { git } from '@percy/env/dist/utils'; import pkg from '../package.json'; -import { - sha256hash, - base64encode, - pool, - httpAgentFor, - request -} from './utils'; +import { sha256hash, base64encode, pool } from './utils'; +import request, { ProxyHttpsAgent } from './request'; // PercyClient is used to communicate with the Percy API to create and finalize // builds and snapshot. Uses @percy/env to collect environment information used @@ -26,9 +21,9 @@ export default class PercyClient { Object.assign(this, { token, apiUrl, - httpAgent: httpAgentFor(apiUrl), clientInfo: new Set([].concat(clientInfo)), environmentInfo: new Set([].concat(environmentInfo)), + httpsAgent: new ProxyHttpsAgent({ keepAlive: true, maxSockets: 5 }), env: new PercyEnvironment(process.env), // build info is stored for reference build: { id: null, number: null, url: null } @@ -81,7 +76,7 @@ export default class PercyClient { get(path) { return request(`${this.apiUrl}/${path}`, { method: 'GET', - agent: this.httpAgent, + agent: this.httpsAgent, headers: this.headers() }); } @@ -90,7 +85,7 @@ export default class PercyClient { post(path, body = {}) { return request(`${this.apiUrl}/${path}`, { method: 'POST', - agent: this.httpAgent, + agent: this.httpsAgent, body: JSON.stringify(body), headers: this.headers({ 'Content-Type': 'application/vnd.api+json' diff --git a/packages/client/src/request.js b/packages/client/src/request.js new file mode 100644 index 000000000..96db80ef5 --- /dev/null +++ b/packages/client/src/request.js @@ -0,0 +1,174 @@ +import url from 'url'; +import net from 'net'; +import tls from 'tls'; +import http from 'http'; +import https from 'https'; +import { retry, hostnameMatches } from './utils'; + +const CRLF = '\r\n'; +const STATUS_REG = /^HTTP\/1.[01] (\d*)/; +const RETRY_ERROR_CODES = [ + 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', + 'EHOSTUNREACH', 'EAI_AGAIN' +]; + +// Proxified https agent +export class ProxyHttpsAgent extends https.Agent { + // enforce request options + addRequest(request, options) { + options.href ||= url.format({ + protocol: options.protocol, + hostname: options.hostname, + port: options.port, + slashes: true + }) + options.path; + + options.uri ||= new URL(options.href); + + let proxyUrl = (options.uri.protocol === 'https:' && + (process.env.https_proxy || process.env.HTTPS_PROXY)) || + (process.env.http_proxy || process.env.HTTP_PROXY); + + let shouldProxy = !!proxyUrl && !hostnameMatches(( + process.env.no_proxy || process.env.NO_PROXY + ), options.href); + + if (shouldProxy) options.proxy = new URL(proxyUrl); + + // useful when testing + options.rejectUnauthorized ??= this.rejectUnauthorized; + + return super.addRequest(request, options); + } + + // proxy https requests using a TLS connection + createConnection(options, callback) { + let { uri, proxy } = options; + let isProxyHttps = proxy?.protocol === 'https:'; + + if (!proxy) { + return super.createConnection(options, callback); + } else if (proxy.protocol !== 'http:' && !isProxyHttps) { + throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`); + } + + // setup socket and listeners + let socket = (isProxyHttps ? tls : net).connect({ + ...options, + host: proxy.hostname, + port: proxy.port + }); + + let handleError = err => { + socket.destroy(err); + callback(err); + }; + + let handleClose = () => handleError( + new Error('Connection closed while sending request to upstream proxy') + ); + + let buffer = ''; + let handleData = data => { + buffer += data.toString(); + // haven't received end of headers yet, keep buffering + if (!buffer.includes(CRLF.repeat(2))) return; + // stop listening after end of headers + socket.off('data', handleData); + + if (buffer.match(STATUS_REG)?.[1] !== '200') { + return handleError(new Error( + 'Error establishing proxy connection. ' + + `Response from server was: ${buffer}` + )); + } + + options.socket = socket; + options.servername = uri.hostname; + // callback not passed in so not to be added as a listener + callback(null, super.createConnection(options)); + }; + + // write proxy connect message to the socket + let connectMessage = [ + `CONNECT ${uri.host} HTTP/1.1`, + `Host: ${uri.host}` + ]; + + if (proxy.username) { + let auth = proxy.username; + if (proxy.password) auth += `:${proxy.password}`; + + connectMessage.push(`Proxy-Authorization: basic ${ + Buffer.from(auth).toString('base64') + }`); + } + + connectMessage = connectMessage.join(CRLF); + connectMessage += CRLF.repeat(2); + + socket + .on('error', handleError) + .on('close', handleClose) + .on('data', handleData) + .write(connectMessage); + } +} + +// Returns true or false if an error should cause the request to be retried +function shouldRetryRequest(error) { + if (error.response) { + return error.response.status >= 500 && error.response.status < 600; + } else if (error.code) { + return RETRY_ERROR_CODES.includes(error.code); + } else { + return false; + } +} + +// Returns a promise that resolves when the request is successful and rejects +// when a non-successful response is received. The rejected error contains +// response data and any received error details. Server 500 errors are retried +// up to 5 times at 50ms intervals. +export default function request(url, { body, ...options }) { + /* istanbul ignore next: the client api is https only, but this helper is borrowed in some + * cli-exec commands for its retryability with the internal api */ + let { request } = url.startsWith('https:') ? https : http; + let { protocol, hostname, port, pathname, search } = new URL(url); + options = { ...options, protocol, hostname, port, path: pathname + search }; + + return retry((resolve, reject, retry) => { + let handleError = error => { + return shouldRetryRequest(error) + ? retry(error) : reject(error); + }; + + request(options) + .on('response', res => { + let status = res.statusCode; + let raw = ''; + + res.setEncoding('utf8') + .on('data', chunk => (raw += chunk)) + .on('error', handleError) + .on('end', () => { + let body = raw; + try { body = JSON.parse(raw); } catch (e) {} + + if (status >= 200 && status < 300) { + resolve(body); + } else { + handleError(Object.assign(new Error(), { + response: { status, body }, + // use first error detail or the status message + message: body?.errors?.find(e => e.detail)?.detail || ( + `${status} ${res.statusMessage || raw}` + ) + })); + } + }); + }) + .on('error', handleError) + .end(body); + }); +} diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 96fc893e2..627dbe00c 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -1,5 +1,4 @@ import crypto from 'crypto'; -import { URL } from 'url'; // Returns a sha256 hash of a string. export function sha256hash(content) { @@ -60,7 +59,7 @@ export function pool(generator, context, concurrency) { // will recursively call the function at the specified interval until retries // are exhausted, at which point the promise will reject with the last error // passed to `retry`. -function retry(fn, { retries = 5, interval = 50 } = {}) { +export function retry(fn, { retries = 5, interval = 50 } = {}) { return new Promise((resolve, reject) => { // run the function, decrement retries let run = () => { @@ -82,78 +81,40 @@ function retry(fn, { retries = 5, interval = 50 } = {}) { }); } -// Returns the appropriate http or https module for a given URL. -function httpModuleFor(url) { - return url.match(/^https:\/\//) ? require('https') : require('http'); -} - -// Returns the appropriate http or https Agent instance for a given URL. -export function httpAgentFor(url) { - let { Agent } = httpModuleFor(url); - - return new Agent({ - keepAlive: true, - maxSockets: 5 - }); -} - -const RETRY_ERROR_CODES = [ - 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', - 'EHOSTUNREACH', 'EAI_AGAIN' -]; - -// Returns true or false if an error should cause the request to be retried -function shouldRetryRequest(error) { - if (error.response) { - return error.response.status >= 500 && error.response.status < 600; - } else if (error.code) { - return RETRY_ERROR_CODES.includes(error.code); - } else { - return false; +// Returns true if the URL hostname matches any patterns +export function hostnameMatches(patterns, url) { + let subject = new URL(url); + + /* istanbul ignore next: only strings are provided internally by the client proxy; core (which + * borrows this util) sometimes provides an array of patterns or undefined */ + patterns = typeof patterns === 'string' + ? patterns.split(/[\s,]+/) + : [].concat(patterns); + + for (let pattern of patterns) { + if (pattern === '*') return true; + if (!pattern) continue; + + // parse pattern + let { groups: rule } = pattern.match( + /^(?.+?)(?::(?\d+))?$/ + ); + + // missing a hostname or ports do not match + if (!rule.hostname || (rule.port && rule.port !== subject.port)) { + continue; + } + + // wildcards are treated the same as leading dots + rule.hostname = rule.hostname.replace(/^\*/, ''); + + // hostnames are equal or end with a wildcard rule + if (rule.hostname === subject.hostname || + (rule.hostname.startsWith('.') && + subject.hostname.endsWith(rule.hostname))) { + return true; + } } -} -// Returns a promise that resolves when the request is successful and rejects -// when a non-successful response is received. The rejected error contains -// response data and any received error details. Server 500 errors are retried -// up to 5 times at 50ms intervals. -export function request(url, { body, ...options }) { - let http = httpModuleFor(url); - let { protocol, hostname, port, pathname, search } = new URL(url); - options = { ...options, protocol, hostname, port, path: pathname + search }; - - return retry((resolve, reject, retry) => { - let handleError = error => { - return shouldRetryRequest(error) - ? retry(error) : reject(error); - }; - - http.request(options) - .on('response', res => { - let status = res.statusCode; - let raw = ''; - - res.setEncoding('utf8') - .on('data', chunk => (raw += chunk)) - .on('error', handleError) - .on('end', () => { - let body = raw; - try { body = JSON.parse(raw); } catch (e) {} - - if (status >= 200 && status < 300) { - resolve(body); - } else { - handleError(Object.assign(new Error(), { - response: { status, body }, - // use first error detail or the status message - message: body?.errors?.find(e => e.detail)?.detail || ( - `${status} ${res.statusMessage || raw}` - ) - })); - } - }); - }) - .on('error', handleError) - .end(body); - }); + return false; } diff --git a/packages/client/test/certs/test.crt b/packages/client/test/certs/test.crt new file mode 100644 index 000000000..b8bad337b --- /dev/null +++ b/packages/client/test/certs/test.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCyX37Mj8zDtDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjEwNDI4MTgzMjA5WhcNMzEwNDI2MTgzMjA5WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT +3bm7xMop/ZiyU1dRZNOhfd8vhEaIsT2bs0mtZ8CP0bgtMz0qqF9R/bnoOmg0mE3S +C+eZEmsrCkqBUWN673SVkJH/B0umvMajEM0JZZxCE0mmx+MT5X5J60UEKipCxOR5 +i+fNObwoY0sly9mFGwpYZkzRLzxB2JLUwRyqkTvODcIIs2qDxUiVgT6pTFM4noMn +u9ev+OoAgWhPoJ/dzf2w+U/dXDB3oWskbrYoN2deEKHfwcmkh4lFuuU3V2+eAaqG +l2wdZvrjml2HmIeXF/Ae/BOLFIoORLVOrGzBYVU+Hoz+I6P3q9IplLKePzu8pAtI +jPrWQF2PAtRSbcSh2K8FAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAKqkWAgpl3Qa +7hUc2BaGmJcuVNI/QnuyRBjp1TlIprEFpe3JxsNLT0yN052hlTAU2d5yYPF7D+e4 +uY+opg+Z4t/rc/JrQoupihh523MIOLAXLzjXcfb16qQ3v78rMIdDZWuzW/8r+u3m +vD7kFfInYy7jS4o5wNCcU7pFDKsbhd3FeaoueVqihLCBzvnoOuzlIjm/p83BP4gq +8mo0sSZtYXId3SQ7szLu7PN6hqZm/gFrqplzwOGfoidepwEoG/ZZMWZtvoOg0cab +7aS4PHUipjtzFW8CHHtA5IL9JOlKutP1Bv5U+sV/BRClHzcUnL4oJux9zZoxyZn9 +1E9tlgkXsSQ= +-----END CERTIFICATE----- diff --git a/packages/client/test/certs/test.key b/packages/client/test/certs/test.key new file mode 100644 index 000000000..2a43f582f --- /dev/null +++ b/packages/client/test/certs/test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDT3bm7xMop/Ziy +U1dRZNOhfd8vhEaIsT2bs0mtZ8CP0bgtMz0qqF9R/bnoOmg0mE3SC+eZEmsrCkqB +UWN673SVkJH/B0umvMajEM0JZZxCE0mmx+MT5X5J60UEKipCxOR5i+fNObwoY0sl +y9mFGwpYZkzRLzxB2JLUwRyqkTvODcIIs2qDxUiVgT6pTFM4noMnu9ev+OoAgWhP +oJ/dzf2w+U/dXDB3oWskbrYoN2deEKHfwcmkh4lFuuU3V2+eAaqGl2wdZvrjml2H +mIeXF/Ae/BOLFIoORLVOrGzBYVU+Hoz+I6P3q9IplLKePzu8pAtIjPrWQF2PAtRS +bcSh2K8FAgMBAAECggEBAKmvVteNWEFjS83fM/sLnvgjgQJklb1a/zXZ1XOdujs1 +w6Xn+OBWc+mOJjxZsyYUqZdGU5pkhxK0rlF+ZweKCzzSyiuQo0WKwijOBvm0uP6u +xfle9H71+jynwuIAB1LssPSsWd4jlJBgXkqKRs/1hUahwHp1s3QlSgw+EoCFy3lO +UyUoqmH5MKLNT+IC1psfPTcBgk+O3HMOIrg9aLndhR/AIUYfvFaDdC3paubjPgzq +ds+k8+9A9ICUFZdj6E4Gw/MmscIFOGKdeCTjBnpY77padrvepMCaA3Nzvvr+iYdj +wwgHrETmUyocjrHTIvVOEbM9mq/tc5cVLBFXsswtNm0CgYEA/aPSMlJDbZRCW0Rg +avU/HriO9mjHYvxIzmjol76W9ACVcwGMveJgmlWE6zQTpXFmJKncL8550cGztkWC +EPRs45eG+f9TI7ohICKtH76D2oZotFYvNokxF/XnfOjfuRKb0n1ucpyzWlRiqHpF +FAya+2q+AhgyeFfYeSEFpbbyKfsCgYEA1dZl1oiqRR0Qxpe70eCApfzZlJ4kwQMH +OHKlPXAzLNZ4Wg4xIEshXS/xRnyyL+KlSpRk3nPgNrI6HsJ/aQ+bcwCW/S7Oc01I +ZU1+VHPQL5XS8OlmS9UHp8vdm82I8BytealHIxTzTsT4aBLjY6abjI0FVpoMziOH +7MA6Ln76Ov8CgYBRAbhJUBKu9bH3ui/dGTSumB04v6AmkhKiscjPZhSKG4GfuHf9 +0UYvJG8OO5Smuz/3J7TmI9iuUGIYLbzrs1Tvn16Bi7U+7NxVih2mzM8JxPG93uS3 +Uzu1vljPgQSq9DGGGX9j5X42tErKKjrTu27oK2BCBP5hhxThItXN5k8TbwKBgGRT +ftw0qo5aoLBMKFbD2hgGlZ7gw6W64fxd7aDxr1DuHvFBj1LzbOfnwm+ruX41/A8N +qHWmMB/5ZsNfxZ9pLym5sR2AhGQcckb1ILxGyfpJdPqKxu/1Nu5G++ZJfGILUmiu +Py36el0OlO1fT0hFtt0unL6Q8EkW6oLtfV6rPIPJAoGAKf8t4UzlfEXf71RrX94E +y5bpi1yhxRHgac2fnCwa/u+PYDLKXviWlluJgzB8aoEHz2em2QOEfaFzfa1JW8R+ +eaEAuQCpbXEA8eVT82V76uT5RBKPNCEK7fSFf1a1ZUO442AEwtHwOwIBQqzA4XvZ +KF2JXsWy1k9/9UQf6lo3CHk= +-----END PRIVATE KEY----- diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index e7c0b186a..9194bed7b 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -18,21 +18,6 @@ describe('PercyClient', () => { mock.stopAll(); }); - it('uses the correct http agent determined by the apiUrl', () => { - let httpsAgent = require('https').Agent; - let httpAgent = require('http').Agent; - - expect(client.httpAgent).toBeInstanceOf(httpsAgent); - - client = new PercyClient({ - token: 'PERCY_AGENT', - apiUrl: 'http://localhost' - }); - - expect(client.httpAgent).not.toBeInstanceOf(httpsAgent); - expect(client.httpAgent).toBeInstanceOf(httpAgent); - }); - describe('#userAgent()', () => { it('contains client and environment information', () => { expect(client.userAgent()).toMatch( diff --git a/packages/client/test/proxy.test.js b/packages/client/test/proxy.test.js new file mode 100644 index 000000000..00b38e41d --- /dev/null +++ b/packages/client/test/proxy.test.js @@ -0,0 +1,229 @@ +import fs from 'fs'; +import net from 'net'; +import http from 'http'; +import https from 'https'; +import path from 'path'; +import PercyClient from '../src'; + +const ssl = { + cert: fs.readFileSync(path.join(__dirname, 'certs/test.crt')), + key: fs.readFileSync(path.join(__dirname, 'certs/test.key')) +}; + +function createTestServer(http, port, handler) { + let connections = new Set(); + let requests = []; + + let server = http.createServer(ssl, (req, res) => { + req.on('data', chunk => { + req.body = (req.body || '') + chunk; + }).on('end', () => { + requests.push(req); + if (handler) handler(req, res); + else res.writeHead(200, {}).end('test'); + }); + }); + + server.on('connection', socket => { + connections.add(socket.on('close', () => { + connections.delete(socket); + })); + }); + + return { + port, + server, + requests, + + async start() { + await new Promise(r => server.listen(port, r)); + return this; + }, + + async close() { + connections.forEach(s => s.destroy()); + await new Promise(r => server.close(r)); + } + }; +} + +function createProxyServer(http, port, options = {}) { + let connects = []; + + let proxy = createTestServer(http, port, (req, res) => { + res.writeHead(405, {}).end('Method not allowed'); + }); + + proxy.server.on('connect', (req, client, head) => { + if (options.shouldConnect && !options.shouldConnect(req, client)) { + client.write('HTTP/1.1 403 FORBIDDEN\r\n'); + client.write('\r\n'); // end headers + return client.end(); + } + + let socket = net.connect({ + host: 'localhost', + port: mitm.port, + rejectUnauthorized: false + }, () => { + connects.push(req); + client.write('HTTP/1.1 200 OK\r\n'); + client.write('\r\n'); // end headers + socket.pipe(client); + client.pipe(socket); + }); + }); + + let mitm = createTestServer(https, port + 1, (req, res) => { + let { connection, headers, url, method } = req; + url = `${connection.encrypted ? 'https' : 'http'}://${headers.host}${url}`; + + https.request(url, { + method, + headers, + rejectUnauthorized: false + }).on('response', remote => { + remote.setEncoding('utf8'); + remote.on('data', chunk => (remote.body = (remote.body || '') + chunk)); + remote.on('end', () => { + res.writeHead(remote.statusCode, remote.headers) + .end(`${remote.body} proxied`); + }); + }).end(req.body); + }); + + return { + options, + connects, + + async start() { + await Promise.all([proxy.start(), mitm.start()]); + return this; + }, + + async close() { + await Promise.all([proxy.close(), mitm.close()]); + } + }; +} + +describe('Proxied PercyClient', () => { + let proxy, server, client; + + beforeEach(async () => { + process.env.HTTP_PROXY = 'http://localhost:1337'; + process.env.NO_PROXY = 'localhost:8081'; + + proxy = await createProxyServer(http, 1337).start(); + server = await createTestServer(https, 8080).start(); + + client = new PercyClient({ + token: 'PERCY_TOKEN', + apiUrl: 'https://localhost:8080' + }); + + client.httpsAgent.rejectUnauthorized = false; + }); + + afterEach(async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.NO_PROXY; + await server?.close(); + await proxy?.close(); + }); + + it('is sent through the proxy', async () => { + await expectAsync(client.get('foo')) + .toBeResolvedTo('test proxied'); + expect(server.requests[0].url).toEqual('/foo'); + expect(server.requests[0].method).toBe('GET'); + expect(server.requests[0].headers).toEqual( + jasmine.objectContaining({ + authorization: 'Token token=PERCY_TOKEN' + }) + ); + + await expectAsync(client.post('foo', { test: '123' })) + .toBeResolvedTo('test proxied'); + expect(server.requests[1].url).toEqual('/foo'); + expect(server.requests[1].method).toBe('POST'); + expect(server.requests[1].body).toEqual('{"test":"123"}'); + expect(server.requests[1].headers).toEqual( + jasmine.objectContaining({ + authorization: 'Token token=PERCY_TOKEN', + 'content-type': 'application/vnd.api+json' + }) + ); + }); + + it('is not proxied when matching NO_PROXY', async () => { + process.env.NO_PROXY = 'localhost:8080'; + await expectAsync(client.get('foo')).toBeResolvedTo('test'); + expect(proxy.connects).toEqual([]); + }); + + it('is not proxied when the NO_PROXY list has a wildcard hostname', async () => { + // test coverage for multiple, empty, non-matching, and wildcard + process.env.NO_PROXY = ', .example.com, *'; + await expectAsync(client.get('foo')).toBeResolvedTo('test'); + expect(proxy.connects).toEqual([]); + }); + + it('is sent with basic proxy auth username', async () => { + process.env.HTTP_PROXY = 'http://user@localhost:1337'; + await expectAsync(client.get('foo')).toBeResolvedTo('test proxied'); + + expect(proxy.connects[0].headers).toEqual( + jasmine.objectContaining({ + 'proxy-authorization': 'basic dXNlcg==' + }) + ); + }); + + it('is sent with basic proxy auth username and password', async () => { + process.env.HTTP_PROXY = 'http://user:pass@localhost:1337'; + await expectAsync(client.get('foo')).toBeResolvedTo('test proxied'); + + expect(proxy.connects[0].headers).toEqual( + jasmine.objectContaining({ + 'proxy-authorization': 'basic dXNlcjpwYXNz' + }) + ); + }); + + it('can be proxied through an https proxy', async () => { + proxy.close(); + + proxy = await createProxyServer(https, 1337).start(); + process.env.HTTPS_PROXY = 'https://localhost:1337'; + + await expectAsync(client.get('foo')).toBeResolvedTo('test proxied'); + }); + + it('throws an error for unsupported proxy protocols', async () => { + process.env.HTTP_PROXY = 'socks5://localhost:1337'; + + await expectAsync(client.get('foo')) + .toBeRejectedWithError('Unsupported proxy protocol: socks5:'); + }); + + it('throws unexpected connection errors', async () => { + let err = new Error('Unexpected'); + + spyOn(net.Socket.prototype, 'write') + .and.callFake(function() { this.emit('error', err); }); + + await expectAsync(client.get('foo')).toBeRejectedWith(err); + }); + + it('throws when the proxy connection can not be established', async () => { + proxy.options.shouldConnect = () => false; + + await expectAsync(client.get('foo')).toBeRejectedWithError([ + 'Error establishing proxy connection.', + 'Response from server was:', + 'HTTP/1.1 403 FORBIDDEN\r\n\r\n' + ].join(' ')); + }); +}); From 47028a45d0b14d2473218e4e870a19f1d794ec67 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Fri, 7 May 2021 15:55:39 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=20Use=20new=20internal=20client?= =?UTF-8?q?=20util=20for=20domain=20hostname=20matching=20in=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/discovery/discoverer.js | 8 ++-- packages/core/src/utils/url.js | 45 +---------------------- 2 files changed, 5 insertions(+), 48 deletions(-) diff --git a/packages/core/src/discovery/discoverer.js b/packages/core/src/discovery/discoverer.js index 6a288559e..fb83cf9d9 100644 --- a/packages/core/src/discovery/discoverer.js +++ b/packages/core/src/discovery/discoverer.js @@ -2,7 +2,7 @@ import logger from '@percy/logger'; import Queue from '../queue'; import assert from '../utils/assert'; import { createLocalResource } from '../utils/resources'; -import { hostname, normalizeURL, domainMatch } from '../utils/url'; +import { hostname, normalizeURL, hostnameMatches } from '../utils/url'; import Browser from './browser'; const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308]; @@ -113,9 +113,9 @@ export default class PercyDiscoverer { } else { // do not resolve resources that should not be captured assert(( - domainMatch(rootHost, url) || - domainMatch(allowedHostnames, url) || - domainMatch(this.config.allowedHostnames, url) + hostnameMatches(rootHost, url) || + hostnameMatches(allowedHostnames, url) || + hostnameMatches(this.config.allowedHostnames, url) ), 'is remote', meta); await request.continue(); diff --git a/packages/core/src/utils/url.js b/packages/core/src/utils/url.js index c3c54466d..1f49b6af8 100644 --- a/packages/core/src/utils/url.js +++ b/packages/core/src/utils/url.js @@ -1,4 +1,5 @@ import { URL } from 'url'; +export { hostnameMatches } from '@percy/client/dist/utils'; // Returns the hostname portion of a URL. export function hostname(url) { @@ -10,47 +11,3 @@ export function normalizeURL(url) { let { protocol, host, pathname, search } = new URL(url); return `${protocol}//${host}${pathname}${search}`; } - -// Returns true or false if the host matches the domain. When `isWild` is true, -// it will also return true if the host matches end of the domain. -function domainCheck(domain, host, isWild) { - if (host === domain) { - return true; - } - - if (isWild && host) { - let last = host.lastIndexOf(domain); - return (last >= 0 && ((last + domain.length) === host.length)); - } - - return false; -} - -// Returns true or false if `url` matches the provided domain `pattern`. -export function domainMatch(patterns, url) { - for (let pattern of [].concat(patterns)) { - if (pattern === '*') { - return true; - } else if (!pattern) { - continue; - } - - // check for wildcard patterns - let isWild = (pattern.indexOf('*.') === 0) || (pattern.indexOf('*/') === 0); - // get the pattern's domain and path prefix - let slashed = pattern.split('/'); - let domain = isWild ? slashed.shift().substr(2) : slashed.shift(); - let pathprefix = `/${slashed.join('/')}`; - - // parse the provided URL - let { hostname, pathname } = new URL(url); - - // check that the URL matches the pattern's domain and path prefix - if (domainCheck(domain, hostname, isWild) && - pathname.indexOf(pathprefix) === 0) { - return true; - } - } - - return false; -} From f7f6cf949243bc1c4896c378e739716cb2eef7b1 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Fri, 7 May 2021 15:56:10 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Update=20internal=20client?= =?UTF-8?q?=20util=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli-exec/src/commands/exec/ping.js | 2 +- packages/cli-exec/src/commands/exec/stop.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-exec/src/commands/exec/ping.js b/packages/cli-exec/src/commands/exec/ping.js index edd7fbec8..1185e523b 100644 --- a/packages/cli-exec/src/commands/exec/ping.js +++ b/packages/cli-exec/src/commands/exec/ping.js @@ -1,5 +1,5 @@ import Command, { flags } from '@percy/cli-command'; -import { request } from '@percy/client/dist/utils'; +import request from '@percy/client/dist/request'; import logger from '@percy/logger'; import execFlags from '../../flags'; diff --git a/packages/cli-exec/src/commands/exec/stop.js b/packages/cli-exec/src/commands/exec/stop.js index 6033781a4..11c123038 100644 --- a/packages/cli-exec/src/commands/exec/stop.js +++ b/packages/cli-exec/src/commands/exec/stop.js @@ -1,5 +1,5 @@ import Command, { flags } from '@percy/cli-command'; -import { request } from '@percy/client/dist/utils'; +import request from '@percy/client/dist/request'; import logger from '@percy/logger'; import execFlags from '../../flags'; From 96bcf417df7caea8a7ce43cad24d8cad2d8fe04a Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Fri, 7 May 2021 17:29:22 -0500 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=92=9A=20Bump=20CI=20cache=20key=20fo?= =?UTF-8?q?r=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/.cache-key | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/.cache-key b/.github/.cache-key index 0cfbf0888..00750edc0 100644 --- a/.github/.cache-key +++ b/.github/.cache-key @@ -1 +1 @@ -2 +3