diff --git a/src/features/popup/NetworkPage.tsx b/src/features/popup/NetworkPage.tsx index 490c026..495aa45 100644 --- a/src/features/popup/NetworkPage.tsx +++ b/src/features/popup/NetworkPage.tsx @@ -1208,8 +1208,8 @@ export default function NetworkPage() { className='w-full resize-none rounded-lg border border-slate-300 bg-white px-2.5 py-2 font-mono text-xs leading-5 text-slate-700 placeholder:text-slate-400 outline-none transition-colors focus:border-blue-500' />

- 每行一个域名或 Chrome - 代理绕过规则;代理模式、自动模式与每个自定义代理模式都会独立保存。 + 每行一个域名或 Chrome 代理绕过规则;支持 `*.example.com` + 匹配子域名。代理模式、自动模式与每个自定义代理模式都会独立保存。

diff --git a/src/utils/network.ts b/src/utils/network.ts index b7e1954..8c1e50d 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -44,6 +44,8 @@ function normalizeHostPattern(value: string): string { } function extractHostPatternFromUrlLike(value: string): string | null { + const rawHost = value.replace(WILDCARD_SCHEME_RE, '').replace(URL_SCHEME_RE, '').split(/[/?#]/)[0] + const hasSubdomainWildcard = /^(?:\*|%2a)\./i.test(stripPortFromHostPattern(rawHost)) let urlInput = `http://${value}` if (WILDCARD_SCHEME_RE.test(value)) { urlInput = value.replace(WILDCARD_SCHEME_RE, 'http://') @@ -53,13 +55,10 @@ function extractHostPatternFromUrlLike(value: string): string | null { try { const url = new URL(urlInput) - return normalizeHostPattern(url.hostname) + const hostname = normalizeHostPattern(url.hostname) + return hasSubdomainWildcard ? `*.${hostname.replace(/^(?:\*|%2a)\./i, '')}` : hostname } catch { - const withoutScheme = value.replace(WILDCARD_SCHEME_RE, '').replace(URL_SCHEME_RE, '') - const host = withoutScheme.startsWith('[') - ? withoutScheme.slice(0, withoutScheme.indexOf(']') + 1 || undefined) - : withoutScheme.split(/[/?#]/)[0] - return host ? normalizeHostPattern(host) : null + return null } } diff --git a/tests/network-bypass.test.mjs b/tests/network-bypass.test.mjs new file mode 100644 index 0000000..fc7ab87 --- /dev/null +++ b/tests/network-bypass.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +const networkModule = globalThis.process.env.DEVGO_NETWORK_MODULE + +if (!networkModule) { + throw new Error('DEVGO_NETWORK_MODULE is required') +} + +const { buildNetworkPacScript, normalizeBypassList } = await import(networkModule) + +function evaluatePac(profile, ruleList, host) { + const { data } = buildNetworkPacScript(profile, ruleList) + const FindProxyForURL = new Function( + 'dnsDomainIs', + 'isPlainHostName', + `${data}; return FindProxyForURL`, + )( + (value, domain) => value === domain || value.endsWith(domain), + (value) => !value.includes('.'), + ) + + return FindProxyForURL(`https://${host}/`, host) +} + +const profile = { + scheme: 'http', + host: '127.0.0.1', + port: 7890, + bypassList: ['*.guazi.com'], +} + +test('normalizes wildcard bypass domains and discards invalid entries', () => { + assert.deepEqual(normalizeBypassList([' *.GUAZI.com ', 'invalid host', '*.guazi.com']), [ + '*.guazi.com', + ]) +}) + +test('preserves wildcard syntax when the browser URL API escapes the asterisk', () => { + const NativeURL = globalThis.URL + globalThis.URL = class { + constructor(value) { + return { hostname: new NativeURL(value).hostname.replace('*', '%2a') } + } + } + + try { + assert.deepEqual(normalizeBypassList('*.guazi.com'), ['*.guazi.com']) + } finally { + globalThis.URL = NativeURL + } +}) + +test('restores wildcard syntax from previously encoded bypass rules', () => { + const NativeURL = globalThis.URL + globalThis.URL = class { + constructor(value) { + return { hostname: new NativeURL(value).hostname.replace('*', '%2a') } + } + } + + try { + assert.deepEqual(normalizeBypassList('%2A.GUAZI.COM'), ['*.guazi.com']) + } finally { + globalThis.URL = NativeURL + } +}) + +test('bypasses subdomains but not the wildcard root domain', () => { + assert.equal(evaluatePac(profile, '||guazi.com^', 'www.guazi.com'), 'DIRECT') + assert.equal(evaluatePac(profile, '||guazi.com^', 'guazi.com'), 'PROXY 127.0.0.1:7890') +}) + +test('uses bypass rules before automatic-mode proxy rules', () => { + assert.equal(evaluatePac(profile, '||api.guazi.com^', 'api.guazi.com'), 'DIRECT') +})