Skip to content

Extend the support range for the no_proxy variable #60

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
72 changes: 56 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,23 +298,63 @@ function proxyFromConfigURL(configURL: string | undefined) {
}

function shouldBypassProxy(value: string[]) {
if (value.includes("*")) {
return () => true;
}
const filters = value
.map(s => s.trim().split(':', 2))
.map(([name, port]) => ({ name, port }))
.filter(filter => !!filter.name)
.map(({ name, port }) => {
const domain = name[0] === '.' ? name : `.${name}`;
return { domain, port };
});
if (!filters.length) {
return () => false;
return (hostname: string, port: string): boolean => {
const getIPVersion = (input: string): net.IPVersion | null => {
const version = net.isIP(input);
if (![4, 6].includes(version)) {
return null;
}
return version === 4 ? 'ipv4' : 'ipv6';
};
const blockList = new net.BlockList();
let ipVersion: net.IPVersion | null = null;
for (let denyHost of value) {
if (denyHost === '') {
continue;
}
// Blanket disable
if (denyHost === '*') {
return true;
}
// Full match
if (hostname === denyHost || `${hostname}:${port}` === denyHost) {
return true;
}
// Remove leading dots to validate suffixes
if (denyHost[0] === '.') {
denyHost = denyHost.substring(1);
}
if (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
if (hostname && (ipVersion = getIPVersion(hostname)) && blockList.check(hostname, ipVersion)) {
return true;
}

return false;
}
return (hostname: string, port: string) => filters.some(({ domain, port: filterPort }) => {
return `.${hostname.toLowerCase()}`.endsWith(domain) && (!filterPort || port === filterPort);
});
}

function noProxyFromEnv(envValue?: string) {
Expand Down
84 changes: 84 additions & 0 deletions tests/test-client/src/noProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as assert from 'assert';
import * as vpa from '../../..';

describe("no_proxy value support", () => {
const urlWithDomain = "https://example.com/some/path";
const urlWithDomainAndPort = "https://example.com:80/some/path";
const urlWithSubdomain = "https://internal.example.com/some/path";
const urlWithIPv4 = "https://100.0.0.1/some/path";
const urlWithIPv4AndPort = "https://100.0.0.1:80/some/path";
const urlWithIPv6 = "https://[f182:5b41:6491:49d2:2384:cca9:1ba5:13f1]/some/path";

const baseParams: vpa.ProxyAgentParams = {
resolveProxy: async () => 'PROXY test-http-proxy:3128',
getProxyURL: () => undefined,
getProxySupport: () => 'override',
isAdditionalFetchSupportEnabled: () => true,
addCertificatesV1: () => false,
addCertificatesV2: () => true,
log: console,
getLogLevel: () => vpa.LogLevel.Trace,
proxyResolveTelemetry: () => undefined,
loadAdditionalCertificates: () => Promise.resolve([]),
useHostProxy: true,
env: {},
};

const testNoProxy = async (expectedOutcome: 'handled' | 'bypassed', testUrl: string, denyList: string[]) => {
const { resolveProxyURL } = vpa.createProxyResolver({...baseParams, getNoProxyConfig: () => denyList});
const resolvedUrl = await resolveProxyURL(testUrl)
const outcome = resolvedUrl === undefined ? 'bypassed' : 'handled';
assert.strictEqual(outcome, expectedOutcome, `given a denylist of ${denyList}, proxying ${testUrl} should have been ${expectedOutcome} but was not`);
}

it("proceeds if no denylists are provided", async () => {
await testNoProxy('handled', urlWithDomain, []);
});

it("match wildcard", async () => {
await testNoProxy('bypassed', urlWithDomain, ["*"]);
await testNoProxy('bypassed', urlWithSubdomain, ["*"]);
await testNoProxy('bypassed', urlWithIPv4, ["*"]);
await testNoProxy('bypassed', urlWithIPv6, ["*"]);
});

it("match direct hostname", async () => {
await testNoProxy('bypassed', urlWithDomain, ['example.com']);
await testNoProxy('handled', urlWithDomain, ['otherexample.com']);
// Technically the following are a suffix match but it's a known behavior in the ecosystem
await testNoProxy('bypassed', urlWithDomain, ['.example.com']);
await testNoProxy('handled', urlWithDomain, ['.otherexample.com']);
});

it("match hostname suffixes", async () => {
await testNoProxy('bypassed', urlWithSubdomain, ['example.com']);
await testNoProxy('bypassed', urlWithSubdomain, ['.example.com']);
await testNoProxy('handled', urlWithSubdomain, ['otherexample.com']);
await testNoProxy('handled', urlWithSubdomain, ['.otherexample.com']);
});

it("match hostname with ports", async () => {
await testNoProxy('bypassed', urlWithDomainAndPort, ['example.com:80']);
await testNoProxy('handled', urlWithDomainAndPort, ['otherexample.com:80']);
await testNoProxy('handled', urlWithDomainAndPort, ['example.com:70']);
});

it("match IP addresses", async () => {
await testNoProxy('handled', urlWithIPv4, ['example.com']);
await testNoProxy('handled', urlWithIPv6, ['example.com']);
await testNoProxy('bypassed', urlWithIPv4, ['100.0.0.1']);
await testNoProxy('bypassed', urlWithIPv6, ['f182:5b41:6491:49d2:2384:cca9:1ba5:13f1']);
});

it("match IP addresses with port", async () => {
await testNoProxy('bypassed', urlWithIPv4AndPort, ['100.0.0.1:80']);
await testNoProxy('handled', urlWithIPv4AndPort, ['100.0.0.1:70']);
});

it("match IP addresses with range deny list", async () => {
await testNoProxy('bypassed', urlWithIPv4, ['100.0.0.0/8']);
await testNoProxy('handled', urlWithIPv4, ['10.0.0.0/8']);
await testNoProxy('bypassed', urlWithIPv6, ['f182:5b41:6491:49d2::0/64']);
await testNoProxy('handled', urlWithIPv6, ['100::0/64']);
})
});
2 changes: 1 addition & 1 deletion tests/test-client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2015",
"target": "es2018",
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true
Expand Down