diff --git a/internal/broker/broker.go b/internal/broker/broker.go index de2bd67f0..7b7d34b00 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -585,10 +585,15 @@ func MatchService(host string, targetPort int, path string, services []Service) // matchHostPattern reports the tier and whether pattern matches host. // "*." matches exactly one subdomain level (e.g. "*.github.com" matches // "api.github.com" but not "a.b.github.com" or the bare "github.com"). +// Hostnames are case-insensitive (RFC 4343), so both sides are lowercased. +// Only ASCII letters are folded: Unicode case mapping would turn e.g. +// U+0130 into "i" and let a different IDNA domain match a service. func matchHostPattern(pattern, host string) (tier int, ok bool) { if h, _, err := net.SplitHostPort(pattern); err == nil { pattern = h } + pattern = asciiLower(pattern) + host = asciiLower(host) if pattern == host { return HostTierExact, true } @@ -604,6 +609,17 @@ func matchHostPattern(pattern, host string) (tier int, ok bool) { return 0, false } +// asciiLower lowercases ASCII letters in s and leaves all other bytes alone. +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if 'A' <= c && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + // AnyHostMatches reports whether any service's host pattern matches // host, ignoring paths. Used to filter discovered hosts against the // current service list so hosts that are already covered by a diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go index f066e7e9a..84d98f7f9 100644 --- a/internal/broker/broker_test.go +++ b/internal/broker/broker_test.go @@ -42,6 +42,51 @@ func TestMatchServiceWildcard(t *testing.T) { } } +func TestMatchServiceHostCaseInsensitive(t *testing.T) { + services := []Service{ + {Name: "stripe", Host: "api.stripe.com", Auth: Auth{Type: "bearer", Token: "STRIPE_KEY"}}, + {Name: "github", Host: "*.GitHub.com", Auth: Auth{Type: "bearer", Token: "GH_TOKEN"}}, + } + cases := []struct { + host string + want string + tier int + }{ + {"API.Stripe.COM", "stripe", HostTierExact}, + {"api.github.com", "github", HostTierWildcard}, + {"Uploads.GITHUB.com", "github", HostTierWildcard}, + } + for _, tc := range cases { + r, score := MatchService(tc.host, 0, "/", services) + if r == nil || r.Name != tc.want { + t.Fatalf("host %q: expected %s, got %+v", tc.host, tc.want, r) + } + if score.HostTier != tc.tier { + t.Fatalf("host %q: expected tier %d, got %d", tc.host, tc.tier, score.HostTier) + } + } + if !AnyHostMatches("API.STRIPE.COM", services) { + t.Fatal("expected AnyHostMatches to ignore host case") + } +} + +func TestMatchServiceHostNonASCIINotFolded(t *testing.T) { + services := []Service{ + {Name: "stripe", Host: "api.stripe.com", Auth: Auth{Type: "bearer", Token: "STRIPE_KEY"}}, + {Name: "kube", Host: "*.k8s.example.com", Auth: Auth{Type: "bearer", Token: "KUBE_TOKEN"}}, + } + // U+0130 and the Kelvin sign U+212A lowercase to ASCII "i" and "k" + // under Unicode rules, but name different IDNA domains. + for _, host := range []string{"api.str\u0130pe.com", "api.\u212A8s.example.com"} { + if r, _ := MatchService(host, 0, "/", services); r != nil { + t.Fatalf("host %q: expected no match, got %s", host, r.Name) + } + if AnyHostMatches(host, services) { + t.Fatalf("host %q: expected AnyHostMatches to be false", host) + } + } +} + func TestMatchServiceNoMatch(t *testing.T) { services := []Service{ {Name: "stripe", Host: "api.stripe.com", Auth: Auth{Type: "bearer", Token: "STRIPE_KEY"}},