Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/vs/platform/request/common/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
82 changes: 82 additions & 0 deletions src/vs/platform/request/node/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(/^(?<ip>.*)\/(?<cidrPrefix>\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<Agent> {
Expand All @@ -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 || '')) {
Expand Down
7 changes: 5 additions & 2 deletions src/vs/platform/request/node/requestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface IHTTPConfiguration {
proxy?: string;
proxyStrictSSL?: boolean;
proxyAuthorization?: string;
noProxy?: string[];
}

export interface IRawRequestFunction {
Expand All @@ -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;

Expand All @@ -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<IRequestContext> {
const { proxyUrl, strictSSL } = this;
const { proxyUrl, strictSSL, noProxy } = this;

let shellEnv: typeof process.env | undefined = undefined;
try {
Expand All @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions src/vs/platform/request/test/node/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*---------------------------------------------------------------------------------------------
* 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 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", () => {
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 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);
assert.strictEqual(urlMatchDenyList(urlWithIPv4, ['127.0.0.1']), true);
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);
assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['::0/64']), true);
assert.strictEqual(urlMatchDenyList(urlWithIPv6, ['100::0/64']), false);
});

ensureNoDisposablesAreLeakedInTestSuite();
});