Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
31 changes: 29 additions & 2 deletions rulesengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rulesengine
import (
"log/slog"
neturl "net/url"
"slices"
"strings"
)

Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
82 changes: 82 additions & 0 deletions rulesengine/parse_and_match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down