Skip to content

domain-anchor bypass in SearchDomainSubdomainAndPath rule matching (malformed GFWList subscription line whitelists/proxies an unrelated domain) #569

Description

@router0mail

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions