From 16108f1c5b2da45218d7c0d89689e6ebf8fa4e6f Mon Sep 17 00:00:00 2001 From: Jeremie Laval Date: Fri, 1 Mar 2024 09:13:33 -0500 Subject: [PATCH 1/2] add support for no_proxy in RequestService --- src/vs/platform/request/common/request.ts | 6 ++ src/vs/platform/request/node/proxy.ts | 82 +++++++++++++++++++ .../platform/request/node/requestService.ts | 7 +- .../platform/request/test/node/proxy.test.ts | 64 +++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/vs/platform/request/test/node/proxy.test.ts diff --git a/src/vs/platform/request/common/request.ts b/src/vs/platform/request/common/request.ts index 289ad6740e0c8..2a4453ee5c6b4 100644 --- a/src/vs/platform/request/common/request.ts +++ b/src/vs/platform/request/common/request.ts @@ -150,6 +150,12 @@ function registerProxyConfigurations(scope: ConfigurationScope): void { description: localize('strictSSL', "Controls whether the proxy server certificate should be verified against the list of supplied CAs."), restricted: true }, + 'http.noProxy': { + type: 'array', + default: [], + description: localize('noProxy', "List of hosts which should not use a proxy and are queried directly."), + restricted: true + }, 'http.proxyKerberosServicePrincipal': { type: 'string', markdownDescription: localize('proxyKerberosServicePrincipal', "Overrides the principal service name for Kerberos authentication with the HTTP proxy. A default based on the proxy hostname is used when this is not set."), diff --git a/src/vs/platform/request/node/proxy.ts b/src/vs/platform/request/node/proxy.ts index db448e06cc1c9..3f60a40b15c20 100644 --- a/src/vs/platform/request/node/proxy.ts +++ b/src/vs/platform/request/node/proxy.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { BlockList, IPVersion, isIP } from 'net'; import { parse as parseUrl, Url } from 'url'; import { isBoolean } from 'vs/base/common/types'; @@ -18,9 +19,86 @@ function getSystemProxyURI(requestURL: Url, env: typeof process.env): string | n return null; } +export function urlMatchDenyList(requestUrl: Url, denyList: string[]): boolean { + const getIPVersion = (input: string): IPVersion | null => { + const version = isIP(input); + if (![4, 6].includes(version)) { + return null; + } + return version === 4 ? 'ipv4' : 'ipv6'; + }; + const blockList = new BlockList(); + let ipVersion: IPVersion | null = null; + for (let denyHost of denyList) { + if (denyHost === '') { + continue; + } + // Blanket disable + if (denyHost === '*') { + return true; + } + // Full match + if (requestUrl.hostname === denyHost || requestUrl.host === denyHost) { + return true; + } + // Remove leading dots to validate suffixes + if (denyHost[0] === '.') { + denyHost = denyHost.substring(1); + } + if (requestUrl.hostname?.endsWith(denyHost)) { + return true; + } + // IP+CIDR notation support, add those to our intermediate + // blocklist to be checked afterwards + if (ipVersion = getIPVersion(denyHost)) { + blockList.addAddress(denyHost, ipVersion); + } + const cidrPrefixMatch = denyHost.match(/^(?.*)\/(?\d+)$/); + if (cidrPrefixMatch && cidrPrefixMatch.groups) { + const matchedIP = cidrPrefixMatch.groups['ip']; + const matchedPrefix = cidrPrefixMatch.groups['cidrPrefix']; + if (matchedIP && matchedPrefix) { + ipVersion = getIPVersion(matchedIP); + const prefix = Number(matchedPrefix); + if (ipVersion && prefix) { + blockList.addSubnet(matchedIP, prefix, ipVersion); + } + } + } + } + + // Do a final check using block list if the requestUrl is an IP. + // Importantly domain names are not first resolved to an IP to do this check in + // line with how the rest of the ecosystem behaves + const hostname = requestUrl.hostname; + if (hostname && (ipVersion = getIPVersion(hostname)) && blockList.check(hostname, ipVersion)) { + return true; + } + + return false; +} + +export function shouldProxyUrl(requestUrl: Url, noProxyConfig: string[], env: typeof process.env): boolean { + // If option is set use that over anything else + if (noProxyConfig.length !== 0) { + return !urlMatchDenyList(requestUrl, noProxyConfig); + } + + // Else look at the environment + const noProxyEnv = env.no_proxy || env.NO_PROXY || null; + if (noProxyEnv) { + // Values are expected to be comma-separated, leading/trailing whitespaces are also removed from entries + const envDenyList = noProxyEnv.split(',').map(entry => entry.trim()); + return !urlMatchDenyList(requestUrl, envDenyList); + } + + return true; +} + export interface IOptions { proxyUrl?: string; strictSSL?: boolean; + noProxy?: string[]; } export async function getProxyAgent(rawRequestURL: string, env: typeof process.env, options: IOptions = {}): Promise { @@ -31,6 +109,10 @@ export async function getProxyAgent(rawRequestURL: string, env: typeof process.e return null; } + if (!shouldProxyUrl(requestURL, options.noProxy || [], env)) { + return null; + } + const proxyEndpoint = parseUrl(proxyURL); if (!/^https?:$/.test(proxyEndpoint.protocol || '')) { diff --git a/src/vs/platform/request/node/requestService.ts b/src/vs/platform/request/node/requestService.ts index 23f8f0d44c850..ab5b4922b3da6 100644 --- a/src/vs/platform/request/node/requestService.ts +++ b/src/vs/platform/request/node/requestService.ts @@ -25,6 +25,7 @@ interface IHTTPConfiguration { proxy?: string; proxyStrictSSL?: boolean; proxyAuthorization?: string; + noProxy?: string[]; } export interface IRawRequestFunction { @@ -48,6 +49,7 @@ export class RequestService extends AbstractRequestService implements IRequestSe private proxyUrl?: string; private strictSSL: boolean | undefined; + private noProxy: string[] = []; private authorization?: string; private shellEnvErrorLogged?: boolean; @@ -72,10 +74,11 @@ export class RequestService extends AbstractRequestService implements IRequestSe this.proxyUrl = config?.proxy; this.strictSSL = !!config?.proxyStrictSSL; this.authorization = config?.proxyAuthorization; + this.noProxy = config?.noProxy || []; } async request(options: NodeRequestOptions, token: CancellationToken): Promise { - const { proxyUrl, strictSSL } = this; + const { proxyUrl, strictSSL, noProxy } = this; let shellEnv: typeof process.env | undefined = undefined; try { @@ -91,7 +94,7 @@ export class RequestService extends AbstractRequestService implements IRequestSe ...process.env, ...shellEnv }; - const agent = options.agent ? options.agent : await getProxyAgent(options.url || '', env, { proxyUrl, strictSSL }); + const agent = options.agent ? options.agent : await getProxyAgent(options.url || '', env, { proxyUrl, strictSSL, noProxy }); options.agent = agent; options.strictSSL = strictSSL; diff --git a/src/vs/platform/request/test/node/proxy.test.ts b/src/vs/platform/request/test/node/proxy.test.ts new file mode 100644 index 0000000000000..c19cb027aa4cd --- /dev/null +++ b/src/vs/platform/request/test/node/proxy.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { parse as parseUrl } from 'url'; +import { shouldProxyUrl, urlMatchDenyList } from 'vs/platform/request/node/proxy'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; + + +suite("proxy support", () => { + const urlWithDomain = parseUrl("https://example.com/some/path"); + const urlWithSubdomain = parseUrl("https://internal.example.com/some/path"); + const urlWithIPv4 = parseUrl("https://127.0.0.1/some/path"); + const urlWithIPv6 = parseUrl("https://[::1]/some/path"); + + test("returns true if no denylists are provided", () => { + assert.strictEqual(shouldProxyUrl(urlWithDomain, [], {}), true); + }); + + test("gives precedence to direct config value rather than environment", () => { + assert.strictEqual(shouldProxyUrl(urlWithDomain, ["otherexample.com"], { no_proxy: "example.com" }), true); + assert.strictEqual(shouldProxyUrl(urlWithDomain, ["example.com"], { no_proxy: "otherexample.com" }), false); + }); + + test("match wildcard", () => { + assert.strictEqual(urlMatchDenyList(urlWithDomain, ['*']), true); + assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['*']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['*']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['*']), true); + }); + + test("match direct hostname", () => { + assert.strictEqual(urlMatchDenyList(urlWithDomain, ['example.com']), true); + assert.strictEqual(urlMatchDenyList(urlWithDomain, ['otherexample.com']), false); + // Technically the following are a suffix match but it's a known behavior in the ecosystem + assert.strictEqual(urlMatchDenyList(urlWithDomain, ['.example.com']), true); + assert.strictEqual(urlMatchDenyList(urlWithDomain, ['.otherexample.com']), false); + }); + + test("match hostname suffixes", () => { + assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['example.com']), true); + assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['.example.com']), true); + assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['otherexample.com']), false); + assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['.otherexample.com']), false); + }); + + test("match IP addresses", () => { + assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['example.com']), false); + assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['example.com']), false); + assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['127.0.0.1']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['::1']), true); + }); + + test("match IP addresses with range deny list", () => { + assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['127.0.0.0/8']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['10.0.0.0/8']), false); + assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['::0/64']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['100::0/64']), false); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); From a447e8ab7efe0db123b77596dc7750da5c508d31 Mon Sep 17 00:00:00 2001 From: Jeremie Laval Date: Wed, 6 Mar 2024 09:58:53 -0500 Subject: [PATCH 2/2] add tests for deny list with ports as well --- src/vs/platform/request/test/node/proxy.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/vs/platform/request/test/node/proxy.test.ts b/src/vs/platform/request/test/node/proxy.test.ts index c19cb027aa4cd..ddd95b677b8b4 100644 --- a/src/vs/platform/request/test/node/proxy.test.ts +++ b/src/vs/platform/request/test/node/proxy.test.ts @@ -11,8 +11,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti suite("proxy support", () => { const urlWithDomain = parseUrl("https://example.com/some/path"); + const urlWithDomainAndPort = parseUrl("https://example.com:80/some/path"); const urlWithSubdomain = parseUrl("https://internal.example.com/some/path"); const urlWithIPv4 = parseUrl("https://127.0.0.1/some/path"); + const urlWithIPv4AndPort = parseUrl("https://127.0.0.1:80/some/path"); const urlWithIPv6 = parseUrl("https://[::1]/some/path"); test("returns true if no denylists are provided", () => { @@ -46,6 +48,12 @@ suite("proxy support", () => { assert.strictEqual(urlMatchDenyList(urlWithSubdomain, ['.otherexample.com']), false); }); + test("match hostname with ports", () => { + assert.strictEqual(urlMatchDenyList(urlWithDomainAndPort, ['example.com:80']), true); + assert.strictEqual(urlMatchDenyList(urlWithDomainAndPort, ['otherexample.com:80']), false); + assert.strictEqual(urlMatchDenyList(urlWithDomainAndPort, ['example.com:70']), false); + }); + test("match IP addresses", () => { assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['example.com']), false); assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['example.com']), false); @@ -53,6 +61,11 @@ suite("proxy support", () => { assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['::1']), true); }); + test("match IP addresses with port", () => { + assert.strictEqual(urlMatchDenyList(urlWithIPv4AndPort, ['127.0.0.1:80']), true); + assert.strictEqual(urlMatchDenyList(urlWithIPv4AndPort, ['127.0.0.1:70']), false); + }); + test("match IP addresses with range deny list", () => { assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['127.0.0.0/8']), true); assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['10.0.0.0/8']), false);