Summary
ProxyRules.findMatchedUrlInRules() handles CompiledProxyRuleType.SearchDomainSubdomainAndPath with a domain-anchoring safety check that only runs if Utils.extractHostNameFromInvalidUrl(rule.search) successfully returns a host. When that returns null (whenever rule.search contains a malformed port segment before the first /), the domain check and its continue are skipped, and execution falls through to an unanchored substring search over the entire host+path of the navigated URL.
Root cause
// src/core/ProxyRules.ts:314-345 (duplicated at :432-455)
case CompiledProxyRuleType.SearchDomainSubdomainAndPath:
if (schemaLessUrlLowerCase.startsWith(rule.search))
return rule;
let ruleSearchHost = Utils.extractHostNameFromInvalidUrl(rule.search);
if (ruleSearchHost != null) {
if (ruleSearchHost != domainHostLowerCase && !domainHostLowerCase.endsWith('.' + ruleSearchHost))
continue;
}
if (schemaLessUrlLowerCase.includes('.' + rule.search))
return rule;
break;
Utils.extractHostNameFromInvalidUrl() (src/lib/Utils.ts:332-346) returns null whenever new URL("http://" + rule.search) throws -- e.g. rule.search contains a : followed by a non-numeric token before the first /. Confirmed: extractHostNameFromInvalidUrl("bank.com:xx/foo") -> null (well-formed "bank.com/foo" -> "bank.com", the safe path).
Attack path: remote GFWList subscription import
src/lib/RuleImporter.ts, externalAppRuleParser.GFWList.convertLineRegex() turns any subscription line starting with . into this rule type. A line like @@.mybank.example:xx/account in a subscribed rules feed is parsed to a whitelist rule with search = "mybank.example:xx/account", compiled into WhitelistSubscriptionRules (checked with high priority). Because that search string has an unparseable port segment, the domain-anchor check is skipped for every URL evaluated against it. Any URL whose host+path contains the literal substring .mybank.example:xx/account -- on a completely unrelated domain -- matches the whitelist rule and is sent direct, bypassing the configured proxy.
Proof of concept
Added a test importing the actual ProxyRules and externalAppRuleParser modules directly, run under the repo's own jest/ts-jest config:
$ npx jest src/tests/PoC.substringConfusion.test.ts
PASS src/tests/PoC.substringConfusion.test.ts
PoC: domain-anchor bypass in SearchDomainSubdomainAndPath
v GFWList subscription line with a malformed port produces a rule.search that bypasses host validation
v control: a well-formed rule.search (no malformed port) is correctly host-anchored and does NOT match the attacker URL
Tests: 2 passed, 2 total
Test 1: subscription line @@.mybank.example:xx/account -> findMatchedUrlInRulesInfo("https://tracker.evil-attacker.test/collect?ref=zzz.mybank.example:xx/account", ...) returns the whitelist rule match, i.e. an unrelated host is matched and would be routed direct. Test 2 control: a well-formed rule.search = "mybank.example/account" correctly returns null.
Impact
Confidentiality/privacy bypass: attacker-controlled or attacker-visited domains can be made to skip the user's configured proxy (whitelist match) purely by an unrelated, malformed subscription rule string. The reverse also holds for non-whitelist subscription rules (an unrelated domain gets unexpectedly force-routed through a proxy). Requires an active Rules Subscription (a mainstream feature) whose content contains a GFWList line of the shape .<text-with-a-non-numeric-port>/... -- plausible via a compromised or third-party-hosted subscription fetched over plain http://.
Suggested fix
Treat ruleSearchHost == null as "cannot verify domain, do not match" -- continue instead of falling through to the unanchored .includes() check. Or validate/reject malformed rule.search values at compile time (compileRules()/compileRulesSubscription()) so they're dropped rather than silently degrading to a global substring wildcard.
Disclosure note
No SECURITY.md / private vulnerability reporting found on this repo, so filing as a public issue.
This report was produced with AI assistance (Claude, Anthropic). The PoC was run against the real, unmodified ProxyRules and externalAppRuleParser modules via the repo's own jest/ts-jest test runner.
Summary
ProxyRules.findMatchedUrlInRules() handles CompiledProxyRuleType.SearchDomainSubdomainAndPath with a domain-anchoring safety check that only runs if Utils.extractHostNameFromInvalidUrl(rule.search) successfully returns a host. When that returns null (whenever rule.search contains a malformed port segment before the first /), the domain check and its
continueare skipped, and execution falls through to an unanchored substring search over the entire host+path of the navigated URL.Root cause
Utils.extractHostNameFromInvalidUrl()(src/lib/Utils.ts:332-346) returns null whenevernew URL("http://" + rule.search)throws -- e.g. rule.search contains a:followed by a non-numeric token before the first/. Confirmed:extractHostNameFromInvalidUrl("bank.com:xx/foo")-> null (well-formed"bank.com/foo"->"bank.com", the safe path).Attack path: remote GFWList subscription import
src/lib/RuleImporter.ts,externalAppRuleParser.GFWList.convertLineRegex()turns any subscription line starting with.into this rule type. A line like@@.mybank.example:xx/accountin a subscribed rules feed is parsed to a whitelist rule withsearch = "mybank.example:xx/account", compiled intoWhitelistSubscriptionRules(checked with high priority). Because that search string has an unparseable port segment, the domain-anchor check is skipped for every URL evaluated against it. Any URL whose host+path contains the literal substring.mybank.example:xx/account-- on a completely unrelated domain -- matches the whitelist rule and is sent direct, bypassing the configured proxy.Proof of concept
Added a test importing the actual ProxyRules and externalAppRuleParser modules directly, run under the repo's own jest/ts-jest config:
Test 1: subscription line
@@.mybank.example:xx/account->findMatchedUrlInRulesInfo("https://tracker.evil-attacker.test/collect?ref=zzz.mybank.example:xx/account", ...)returns the whitelist rule match, i.e. an unrelated host is matched and would be routed direct. Test 2 control: a well-formedrule.search = "mybank.example/account"correctly returns null.Impact
Confidentiality/privacy bypass: attacker-controlled or attacker-visited domains can be made to skip the user's configured proxy (whitelist match) purely by an unrelated, malformed subscription rule string. The reverse also holds for non-whitelist subscription rules (an unrelated domain gets unexpectedly force-routed through a proxy). Requires an active Rules Subscription (a mainstream feature) whose content contains a GFWList line of the shape
.<text-with-a-non-numeric-port>/...-- plausible via a compromised or third-party-hosted subscription fetched over plain http://.Suggested fix
Treat
ruleSearchHost == nullas "cannot verify domain, do not match" --continueinstead of falling through to the unanchored.includes()check. Or validate/reject malformedrule.searchvalues at compile time (compileRules()/compileRulesSubscription()) so they're dropped rather than silently degrading to a global substring wildcard.Disclosure note
No SECURITY.md / private vulnerability reporting found on this repo, so filing as a public issue.
This report was produced with AI assistance (Claude, Anthropic). The PoC was run against the real, unmodified ProxyRules and externalAppRuleParser modules via the repo's own jest/ts-jest test runner.