diff --git a/src/main/browser/browser-network-tunnel-proxy-selection.ts b/src/main/browser/browser-network-tunnel-proxy-selection.ts index 6bf1ab72e52..843707319c1 100644 --- a/src/main/browser/browser-network-tunnel-proxy-selection.ts +++ b/src/main/browser/browser-network-tunnel-proxy-selection.ts @@ -30,12 +30,7 @@ export function selectBrowserNetworkTunnelProxy( const bypassRules = fromSettings ? normalizeProxyBypassRules(settings?.httpProxyBypassRules) : getProxyBypassRulesFromEnvironment(env) - if ( - // Match browser sessions' default loopback bypass. - isEligibleLocalCertificateHost(target.host) || - isLoopbackAddress(target.host) || - matchesProxyBypassRules(target, bypassRules, fromSettings ? 'chromium' : 'no-proxy') - ) { + if (matchesProxyBypassRules(target, bypassRules, fromSettings ? 'chromium' : 'no-proxy')) { return { kind: 'direct' } } const url = new URL(proxyUrl) @@ -48,16 +43,20 @@ export function selectBrowserNetworkTunnelProxy( return { kind: 'unsupported', protocol: url.protocol } } -// BlockList also recognizes expanded IPv6 loopback addresses from the SOCKS front end. -const LOOPBACK_ADDRESSES = new BlockList() -LOOPBACK_ADDRESSES.addSubnet('127.0.0.0', 8, 'ipv4') -LOOPBACK_ADDRESSES.addAddress('::1', 'ipv6') -LOOPBACK_ADDRESSES.addSubnet('::ffff:127.0.0.0', 104, 'ipv6') +// BlockList also recognizes expanded IPv6 addresses from the SOCKS front end. +const IMPLICIT_BYPASS_ADDRESSES = new BlockList() +IMPLICIT_BYPASS_ADDRESSES.addSubnet('127.0.0.0', 8, 'ipv4') +IMPLICIT_BYPASS_ADDRESSES.addAddress('::1', 'ipv6') +IMPLICIT_BYPASS_ADDRESSES.addSubnet('::ffff:127.0.0.0', 104, 'ipv6') +IMPLICIT_BYPASS_ADDRESSES.addSubnet('169.254.0.0', 16, 'ipv4') +IMPLICIT_BYPASS_ADDRESSES.addSubnet('fe80::', 10, 'ipv6') -function isLoopbackAddress(host: string): boolean { - const address = normalizeHost(host) - const family = isIP(address) - return family !== 0 && LOOPBACK_ADDRESSES.check(address, family === 4 ? 'ipv4' : 'ipv6') +// Chromium never proxies localhost, loopback or link-local unless `<-loopback>` subtracts them. +function matchesImplicitBypassRules(host: string): boolean { + const family = isIP(host) + return family === 0 + ? isEligibleLocalCertificateHost(host) + : IMPLICIT_BYPASS_ADDRESSES.check(host, family === 4 ? 'ipv4' : 'ipv6') } function normalizeHost(host: string): string { @@ -66,17 +65,22 @@ function normalizeHost(host: string): string { } // Bare hosts match exactly in Chromium rules and include subdomains in NO_PROXY. +// As in Chromium, the last matching rule wins (`<-loopback>` subtracts), then implicit rules apply. export function matchesProxyBypassRules( target: Pick, rules: string, style: 'chromium' | 'no-proxy' ): boolean { const host = normalizeHost(target.host) - return rules + const implicit = matchesImplicitBypassRules(host) + const decisive = rules .split(';') .map((rule) => rule.trim().toLowerCase()) .filter(Boolean) - .some((rule) => matchesBypassRule(host, target.port, rule, style)) + .findLast((rule) => + rule === '<-loopback>' ? implicit : matchesBypassRule(host, target.port, rule, style) + ) + return decisive === undefined ? implicit : decisive !== '<-loopback>' } const SCHEME_PORTS: Record = { http: '80', ws: '80', https: '443', wss: '443' } @@ -110,6 +114,10 @@ function matchesBypassRule( if (rulePort !== undefined && Number(rulePort) !== port) { return false } + const ruleFamily = isIP(ruleHost) + if (ruleFamily !== 0) { + return matchesCidr(host, ruleHost, ruleFamily === 4 ? 32 : 128) + } const pattern = stripTrailingDot(ruleHost.startsWith('.') ? `*${ruleHost}` : ruleHost) if (pattern.includes('*')) { // NO_PROXY also reads `.corp` and `*.corp` as the domain itself; Chromium does not. diff --git a/src/main/browser/browser-network-tunnel-proxy.test.ts b/src/main/browser/browser-network-tunnel-proxy.test.ts index c193ad40afa..860ad0f9420 100644 --- a/src/main/browser/browser-network-tunnel-proxy.test.ts +++ b/src/main/browser/browser-network-tunnel-proxy.test.ts @@ -191,6 +191,8 @@ describe('proxy bypass rules', () => { ['10.0.0.0/8', '10.20.30.40', 443, true, true], ['10.0.0.0/8', '11.0.0.1', 443, false, false], ['[fd00::1]', 'fd00::1', 443, true, true], + ['[fd00::1]', 'fd00:0:0:0:0:0:0:1', 443, true, true], + ['fd00:0::1', '[FD00::1]', 443, true, true], ['https://corp.test', 'corp.test', 443, true, true], ['https://corp.test', 'corp.test', 80, false, false], ['ftp://corp.test', 'corp.test', 21, false, false], @@ -199,9 +201,20 @@ describe('proxy bypass rules', () => { ['192.168.*', '192.168.4.20', 443, true, true], ['api.*.corp.test', 'api.eu.corp.test', 443, true, true], ['api.*.corp.test', 'web.eu.corp.test', 443, false, false], - ['corp.test', 'corp.test.', 443, true, true] + ['corp.test', 'corp.test.', 443, true, true], + ['', '169.254.169.254', 80, true, true], + ['<-loopback>', 'localhost', 3000, false, false], + ['<-loopback>;', 'localhost', 3000, true, true], + [';<-loopback>', 'localhost', 3000, false, false], + [';<-loopback>', 'intranet', 80, true, true] ])('%s against %s:%d (NO_PROXY %s, Chromium %s)', (rule, host, port, noProxy, chromium) => { expect(matchesProxyBypassRules({ host, port }, rule, 'no-proxy')).toBe(noProxy) expect(matchesProxyBypassRules({ host, port }, rule, 'chromium')).toBe(chromium) }) + + it('proxies loopback destinations when the bypass list subtracts them', () => { + const settings = { httpProxyUrl: 'http://proxy.test:8080', httpProxyBypassRules: '<-loopback>' } + const target = { host: '127.0.0.1', port: 3000 } + expect(selectBrowserNetworkTunnelProxy(target, settings, {})).toMatchObject({ kind: 'http' }) + }) })