From 5868eb553530d4f5b7e6941c9b2a72ffbcf4a8ff Mon Sep 17 00:00:00 2001 From: Jeremie Laval Date: Fri, 10 Jan 2025 15:26:34 -0500 Subject: [PATCH 1/2] extend the support range for the `no_proxy` variable --- src/index.ts | 72 +++++++++++++++++++++++++-------- tests/test-client/tsconfig.json | 2 +- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 231ba24..2a4fe9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(/^(?.*)\/(?\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) { diff --git a/tests/test-client/tsconfig.json b/tests/test-client/tsconfig.json index d518893..4396c4e 100644 --- a/tests/test-client/tsconfig.json +++ b/tests/test-client/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "target": "es2015", + "target": "es2018", "esModuleInterop": true, "strict": true, "resolveJsonModule": true From 8f939a60a6d9260ae273987191060c03abe4a169 Mon Sep 17 00:00:00 2001 From: Jeremie Laval Date: Wed, 22 Jan 2025 14:58:02 -0500 Subject: [PATCH 2/2] add missing test file (doh) --- tests/test-client/src/noProxy.test.ts | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test-client/src/noProxy.test.ts diff --git a/tests/test-client/src/noProxy.test.ts b/tests/test-client/src/noProxy.test.ts new file mode 100644 index 0000000..bd62073 --- /dev/null +++ b/tests/test-client/src/noProxy.test.ts @@ -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']); + }) +}); \ No newline at end of file