fix(browser): remote tunnel matches Chromium for IPv6 bypass rules and <-loopback> (#32665)

This commit is contained in:
Kelvin Amoaba
2026-10-24 02:35:00 +00:00
committed by GitHub
parent 33bd67e057
commit 117e186147
2 changed files with 39 additions and 18 deletions
@@ -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<BrowserNetworkTunnelOpen, 'host' | 'port'>,
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<string, string> = { 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.
@@ -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>;<local>', 'localhost', 3000, true, true],
['<local>;<-loopback>', 'localhost', 3000, false, false],
['<local>;<-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' })
})
})