From fe7a199b9acd4ead1ba37a27dd128ddb87709c35 Mon Sep 17 00:00:00 2001 From: Andriy Palamarchuk Date: Sat, 3 Oct 2026 21:47:12 -0400 Subject: [PATCH] fix(rulesengine): match domains case-insensitively and ignore a trailing dot Domain rules compared host labels byte for byte, so `domain=github.com` denied requests for `GitHub.com` or `github.com.`, and a rule written as `domain=GitHub.com` never matched lowercase traffic. All of these are the same DNS name. A `*` label also matched an empty label, so `domain=*.github.com` matched `.github.com` and `a..github.com`. Before matching, remove one trailing dot from the request host and lowercase ASCII letters on both sides. Folding is ASCII-only (RFC 4343): Unicode folding would map U+212A KELVIN SIGN to "k" and let a rule match a host that resolves to a different name. Hosts with an empty label no longer match. Paths and methods stay case-sensitive, and the rule grammar is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 1 + rulesengine/engine.go | 31 ++++++++++- rulesengine/parse_and_match_test.go | 82 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 48903e4..a4c9b65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,7 @@ Important matching rules: - `domain=*.github.com` matches subdomains such as `api.github.com` and deeper subdomains such as `v1.api.github.com`. - `domain=*.github.com` does not match `github.com`. - To allow a base domain and its subdomains, configure both patterns. +- Domain matching ignores ASCII case and one trailing dot on the request host: `domain=github.com` matches `GitHub.com` and `github.com.`. Paths and methods stay case-sensitive. - Path wildcards are segment-based. A wildcard must be a whole path segment. - A trailing `*` segment matches multiple remaining segments: `path=/api/*` matches `/api/v1/users`. diff --git a/rulesengine/engine.go b/rulesengine/engine.go index 4bf6ae4..b1806be 100644 --- a/rulesengine/engine.go +++ b/rulesengine/engine.go @@ -3,6 +3,7 @@ package rulesengine import ( "log/slog" neturl "net/url" + "slices" "strings" ) @@ -82,7 +83,13 @@ func (re *Engine) matches(r Rule, method, url string) bool { // - "*.github.com" matches ONLY subdomains like "api.github.com" (not the base domain) // - To allow both, specify: "github.com, *.github.com" - labels := strings.Split(parsedUrl.Hostname(), ".") + labels := strings.Split(normalizeHostname(parsedUrl.Hostname()), ".") + + // An empty label is not a valid DNS name and must not satisfy `*`. + if slices.Contains(labels, "") { + re.logger.Debug("rule does not match", "reason", "empty host label", "rule", r.Raw, "method", method, "url", url) + return false + } // If the host pattern is longer than the actual host, it's definitely not a match if len(r.HostPattern) > len(labels) { @@ -94,7 +101,7 @@ func (re *Engine) matches(r Rule, method, url string) bool { // "example.com" or "*.example.com" - match from the end (allowing subdomains) for i, lp := range r.HostPattern { labelIndex := len(labels) - len(r.HostPattern) + i - if string(lp) != labels[labelIndex] && lp != "*" { + if toLowerASCII(lp) != labels[labelIndex] && lp != "*" { re.logger.Debug("rule does not match", "reason", "host pattern label mismatch", "rule", r.Raw, "method", method, "url", url, "expected", string(lp), "actual", labels[labelIndex]) return false } @@ -154,3 +161,23 @@ func (re *Engine) matches(r Rule, method, url string) bool { re.logger.Debug("rule matches", "reason", "all patterns matched", "rule", r.Raw, "method", method, "url", url) return true } + +// normalizeHostname puts a request hostname in the form used for matching: +// one trailing dot removed, since "github.com." and "github.com" are the same +// DNS name, and ASCII letters lowercased. +func normalizeHostname(host string) string { + return toLowerASCII(strings.TrimSuffix(host, ".")) +} + +// toLowerASCII lowercases only ASCII letters. DNS names compare +// case-insensitively for ASCII only (RFC 4343). strings.ToLower would also +// fold non-ASCII characters, such as U+212A KELVIN SIGN to "k", letting a +// rule match a host that resolves to a different name. +func toLowerASCII(s string) string { + return strings.Map(func(r rune) rune { + if 'A' <= r && r <= 'Z' { + return r + ('a' - 'A') + } + return r + }, s) +} diff --git a/rulesengine/parse_and_match_test.go b/rulesengine/parse_and_match_test.go index 7da1542..1b01fa1 100644 --- a/rulesengine/parse_and_match_test.go +++ b/rulesengine/parse_and_match_test.go @@ -344,6 +344,88 @@ func TestRoundTripExtraRules(t *testing.T) { expectParse: true, expectMatch: false, }, + { + name: "domain matches request host in any ASCII case", + rules: []string{"domain=github.com"}, + url: "https://GitHub.COM/wibble", + method: "GET", + expectParse: true, + expectMatch: true, + }, + { + name: "domain in any ASCII case matches lowercase request host", + rules: []string{"domain=GitHub.com"}, + url: "https://github.com/wibble", + method: "GET", + expectParse: true, + expectMatch: true, + }, + { + name: "domain matches request host with a trailing dot", + rules: []string{"domain=github.com"}, + url: "https://github.com./wibble", + method: "GET", + expectParse: true, + expectMatch: true, + }, + { + name: "wildcard domain matches subdomain with a trailing dot", + rules: []string{"domain=*.github.com"}, + url: "https://api.github.com./wibble", + method: "GET", + expectParse: true, + expectMatch: true, + }, + { + name: "trailing dot does not let wildcard domain match base domain", + rules: []string{"domain=*.github.com"}, + url: "https://github.com./wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, + { + name: "only one trailing dot is ignored", + rules: []string{"domain=github.com"}, + url: "https://github.com../wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, + { + name: "wildcard domain does not match an empty leading label", + rules: []string{"domain=*.github.com"}, + url: "https://.github.com/wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, + { + name: "wildcard domain does not match an empty inner label", + rules: []string{"domain=*.github.com"}, + url: "https://a..github.com/wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, + { + // U+212A KELVIN SIGN folds to "k" under Unicode case folding, + // but DNS compares only ASCII case-insensitively (RFC 4343). + name: "domain does not match non-ASCII lookalike of request host", + rules: []string{"domain=kubernetes.io"}, + url: "https://Kubernetes.io/wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, + { + name: "path stays case-sensitive", + rules: []string{"domain=github.com path=/wibble"}, + url: "https://GitHub.com/Wibble", + method: "GET", + expectParse: true, + expectMatch: false, + }, } logHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{