-
Notifications
You must be signed in to change notification settings - Fork 40
fix: reject match-all skip_hosts and unreachable routes #839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| // Package hostglob is the one place cortex turns an operator-supplied host | ||
| // pattern into a matcher, and the one place that decides what counts as a | ||
| // dangerously broad pattern. | ||
| // | ||
| // Three packages match a destination Host against operator patterns: | ||
| // | ||
| // - listener/skiphost — a match bypasses the pipeline AND session recording | ||
| // - routing — a match selects token-exchange parameters | ||
| // - plugins/tokenbroker — a match selects broker parameters | ||
| // | ||
| // Each of them used to call glob.Compile(pattern, '.') itself and reason about | ||
| // breadth on its own. That duplication is why skiphost's footgun guard could | ||
| // compare against the literal strings "*" and "**" and miss "***", "{**}", | ||
| // "{*,**}" and "?*", and why the other two had no breadth check at all. One | ||
| // definition here means a fix or an upgrade lands once. | ||
| // | ||
| // The package deliberately depends on nothing inside authlib, so every | ||
| // consumer — listener, routing, plugins — can import it without any risk of | ||
| // an import cycle. | ||
| package hostglob | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| "github.com/gobwas/glob" | ||
| ) | ||
|
|
||
| // Separator is the glob separator for host patterns. With '.', a single "*" | ||
| // is confined to one DNS label ("*.svc.cluster.local" matches | ||
| // "otel.svc.cluster.local" but not "otel.ns.svc.cluster.local") while "**" | ||
| // crosses labels. Every consumer must compile with the same separator or | ||
| // identical patterns would mean different things in different packages. | ||
| const Separator = '.' | ||
|
|
||
| // Compile compiles an operator-supplied host pattern. | ||
| func Compile(pattern string) (glob.Glob, error) { | ||
| return glob.Compile(pattern, Separator) | ||
| } | ||
|
|
||
| // singleLabelProbes are short single-label hostnames of varying length. This | ||
| // is the shape in-cluster traffic actually arrives with: the Host header of a | ||
| // request to a Kubernetes Service is usually the bare service name | ||
| // ("Host: github-tool-mcp", "Host: otel-collector"), not an FQDN. | ||
| // | ||
| // A pattern matching every one of these covers every in-cluster destination, | ||
| // which is why bare "*" is dangerous in skip_hosts even though it matches no | ||
| // FQDN at all. | ||
| var singleLabelProbes = []string{ | ||
| "a", | ||
| "svc", | ||
| "otel-collector", | ||
| "github-tool-mcp", | ||
| } | ||
|
|
||
| // fqdnProbes are multi-label hostnames: in-cluster FQDNs and a public domain. | ||
| // A pattern matching these as well as every singleLabelProbe is match-all in | ||
| // the strongest sense — no host escapes it. | ||
| var fqdnProbes = []string{ | ||
| "a.b", | ||
| "otel-collector.rossoctl-system.svc.cluster.local", | ||
| "api.anthropic.com", | ||
| } | ||
|
|
||
| // MatchesEverySingleLabel reports whether g matches every single-label probe, | ||
| // i.e. whether the pattern exempts (or captures) every in-cluster | ||
| // short-service-name destination. | ||
| // | ||
| // This is a behavioural probe rather than a comparison against known-bad | ||
| // strings on purpose. Under '.'-separated globs all of these are equally | ||
| // broad, and a spelling check catches only the ones someone thought of: | ||
| // | ||
| // "*" — every single label | ||
| // "**", "***", and longer runs of stars | ||
| // "{**}", "{*,**}" — super-star in braces or alternation | ||
| // "?*" — one character then anything | ||
| func MatchesEverySingleLabel(g glob.Glob) bool { | ||
| return matchesAll(g, singleLabelProbes) | ||
| } | ||
|
|
||
| // IsMatchAll reports whether g matches every probe host, single-label and | ||
| // FQDN alike — the strongest form of "this pattern matches everything". | ||
| // | ||
| // Callers deciding whether an earlier entry makes a later one unreachable | ||
| // want this rather than MatchesEverySingleLabel: "*" matches every single | ||
| // label but no FQDN, so it shadows a later "github-tool-mcp" while leaving a | ||
| // later "*.svc.cluster.local" perfectly reachable. | ||
| func IsMatchAll(g glob.Glob) bool { | ||
| return matchesAll(g, singleLabelProbes) && matchesAll(g, fqdnProbes) | ||
| } | ||
|
|
||
| func matchesAll(g glob.Glob, hosts []string) bool { | ||
| for _, h := range hosts { | ||
| if !g.Match(h) { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| // metacharacters are the glob syntax characters. A pattern containing none of | ||
| // them matches exactly one host, which is what lets IsLiteral support exact | ||
| // shadowing detection. | ||
| const metacharacters = `*?[]{}\` | ||
|
|
||
| // IsLiteral reports whether pattern contains no glob syntax, i.e. it matches | ||
| // exactly itself and nothing else. | ||
| // | ||
| // This is what makes unreachable-route detection exact for the common case: a | ||
| // literal later route is shadowed by an earlier pattern if and only if that | ||
| // pattern matches the literal. For a later route that is itself a wildcard, | ||
| // no such exact test exists (it would require deciding glob subsumption), so | ||
| // callers fall back to IsMatchAll on the earlier entry. | ||
| func IsLiteral(pattern string) bool { | ||
| return !strings.ContainsAny(pattern, metacharacters) | ||
| } | ||
|
|
||
| // Shadows reports whether an earlier entry in a first-match-wins list makes a | ||
| // later one unreachable, so callers can reject dead configuration at boot | ||
| // rather than silently ignoring it. | ||
| // | ||
| // Both route lists in authlib are first-match-wins, which makes a broad | ||
| // early pattern quietly destructive: a "***" typo at the top of | ||
| // authproxy-routes, or a plain "*", swallows every carefully-configured route | ||
| // beneath it, and if the shadowing route is a passthrough then token exchange | ||
| // is silently off for hosts the operator explicitly listed. | ||
| // | ||
| // Deliberately conservative — it answers yes only when unreachability is | ||
| // certain: | ||
| // | ||
| // - earlier is match-all, so nothing after it can ever be reached; or | ||
| // - later is a literal host and earlier already matches that exact host. | ||
| // | ||
| // A later wildcard shadowed by a narrower earlier wildcard (say "*.a.com" | ||
| // after "*.*.com") is not reported. Deciding that in general means deciding | ||
| // glob subsumption, and a false positive here fails the pod at boot — so the | ||
| // check stays sound rather than complete. | ||
| func Shadows(earlier glob.Glob, laterPattern string) bool { | ||
| if IsMatchAll(earlier) { | ||
| return true | ||
| } | ||
| if IsLiteral(laterPattern) { | ||
| return earlier.Match(laterPattern) | ||
| } | ||
| return false | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| package hostglob | ||
|
|
||
| import "testing" | ||
|
|
||
| func mustCompile(t *testing.T, pattern string) interface{ Match(string) bool } { | ||
| t.Helper() | ||
| g, err := Compile(pattern) | ||
| if err != nil { | ||
| t.Fatalf("Compile(%q) err = %v", pattern, err) | ||
| } | ||
| return g | ||
| } | ||
|
|
||
| // TestMatchesEverySingleLabel_BroadSpellings covers what a spelling check | ||
| // misses. skiphost's guard used to compare against the literal strings "*" | ||
| // and "**"; every other way of writing the same breadth slipped through and | ||
| // silently bypassed the pipeline for every host. | ||
| func TestMatchesEverySingleLabel_BroadSpellings(t *testing.T) { | ||
| for _, p := range []string{ | ||
| "*", // every single label | ||
| "**", // match-all | ||
| "***", // runs of three or more stars behave as "**" | ||
| "****", // | ||
| "{**}", // super-star in braces | ||
| "{*,**}", // alternation containing a super-star | ||
| "?*", // one character then anything | ||
| } { | ||
| if !MatchesEverySingleLabel(mustCompile(t, p)) { | ||
| t.Errorf("MatchesEverySingleLabel(%q) = false, want true", p) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestMatchesEverySingleLabel_NarrowPatterns is the over-rejection guard. | ||
| // Every pattern here needs a separator or a fixed prefix/suffix, so none | ||
| // covers all in-cluster destinations and all must keep working — a wrongly | ||
| // rejected pattern fails the pod at boot. | ||
| func TestMatchesEverySingleLabel_NarrowPatterns(t *testing.T) { | ||
| for _, p := range []string{ | ||
| "*.*", | ||
| "*.svc.cluster.local", | ||
| "service-*", | ||
| "otel-*", | ||
| "otel-collector*", | ||
| "*-anything", | ||
| "otel-collector.*.svc.cluster.local", | ||
| "**.svc.cluster.local", | ||
| "?", | ||
| "**.**", | ||
| "api.anthropic.com", | ||
| } { | ||
| if MatchesEverySingleLabel(mustCompile(t, p)) { | ||
| t.Errorf("MatchesEverySingleLabel(%q) = true, want false", p) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestIsMatchAll_DistinguishesSingleLabelFromTotal pins the difference | ||
| // between the two predicates. A bare "*" covers every single label but no | ||
| // FQDN, so it is broad enough to reject in skip_hosts yet does not make a | ||
| // later FQDN route unreachable. | ||
| func TestIsMatchAll_DistinguishesSingleLabelFromTotal(t *testing.T) { | ||
| star := mustCompile(t, "*") | ||
| if !MatchesEverySingleLabel(star) { | ||
| t.Error(`MatchesEverySingleLabel("*") = false, want true`) | ||
| } | ||
| if IsMatchAll(star) { | ||
| t.Error(`IsMatchAll("*") = true, want false ("*" matches no FQDN)`) | ||
| } | ||
|
|
||
| for _, p := range []string{"**", "***", "{**}", "{*,**}"} { | ||
| if !IsMatchAll(mustCompile(t, p)) { | ||
| t.Errorf("IsMatchAll(%q) = false, want true", p) | ||
| } | ||
| } | ||
| for _, p := range []string{"*.*", "*.svc.cluster.local", "service-*", "**.svc.cluster.local"} { | ||
| if IsMatchAll(mustCompile(t, p)) { | ||
| t.Errorf("IsMatchAll(%q) = true, want false", p) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestIsLiteral(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| pattern string | ||
| want bool | ||
| }{ | ||
| {"api.example.com", true}, | ||
| {"github-tool-mcp", true}, | ||
| {"", true}, | ||
| {"*", false}, | ||
| {"*.example.com", false}, | ||
| {"?", false}, | ||
| {"{a,b}", false}, | ||
| {"[a-z]", false}, | ||
| {`a\*b`, false}, | ||
| } { | ||
| if got := IsLiteral(tc.pattern); got != tc.want { | ||
| t.Errorf("IsLiteral(%q) = %t, want %t", tc.pattern, got, tc.want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestShadows covers the first-match-wins hazard: an earlier entry that makes | ||
| // a later one unreachable is dead configuration, and both route lists reject | ||
| // it at boot rather than silently ignoring the later entry. | ||
| func TestShadows(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| earlier string | ||
| later string | ||
| want bool | ||
| why string | ||
| }{ | ||
| // A total match-all shadows anything. | ||
| {"**", "api.example.com", true, "match-all shadows every later route"}, | ||
| {"***", "*.example.com", true, "match-all shadows later wildcards too"}, | ||
| {"{*,**}", "github-tool-mcp", true, "brace alternation match-all"}, | ||
|
|
||
| // Exact coverage of a later literal. | ||
| {"api.example.com", "api.example.com", true, "duplicate host"}, | ||
| {"*.example.com", "api.example.com", true, "wildcard covers the later literal"}, | ||
| {"*", "github-tool-mcp", true, "single-label wildcard covers a short service name"}, | ||
|
|
||
| // Not shadowed: "*" is confined to one label. | ||
| {"*", "api.example.com", false, `"*" matches no FQDN`}, | ||
| {"*.example.com", "api.sub.example.com", false, "single label does not span two"}, | ||
|
|
||
| // Not shadowed: unrelated patterns. | ||
| {"otel-*", "github-tool-mcp", false, "prefix does not match"}, | ||
| {"*.metrics.local", "api.example.com", false, "different suffix"}, | ||
|
|
||
| // Deliberately not reported: later is a wildcard and earlier is not | ||
| // match-all. Deciding glob subsumption in general is out of scope, and | ||
| // a false positive would fail the pod at boot. | ||
| {"*.example.com", "api.example.com*", false, "sound, not complete"}, | ||
| } { | ||
| g := mustCompile(t, tc.earlier) | ||
| if got := Shadows(g, tc.later); got != tc.want { | ||
| t.Errorf("Shadows(%q, %q) = %t, want %t (%s)", tc.earlier, tc.later, got, tc.want, tc.why) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestCompile_Separator pins the separator every consumer must share. If this | ||
| // drifts, identical patterns would mean different things in skiphost, routing | ||
| // and tokenbroker. | ||
| func TestCompile_Separator(t *testing.T) { | ||
| if Separator != '.' { | ||
| t.Fatalf("Separator = %q, want '.'", Separator) | ||
| } | ||
| g := mustCompile(t, "*.example.com") | ||
| if !g.Match("api.example.com") { | ||
| t.Error("one label before the suffix must match") | ||
| } | ||
| if g.Match("api.sub.example.com") { | ||
| t.Error("* must not cross a separator") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice that the shortest probe is a single char
"a"— that's exactly what lets genuinely-narrow patterns like??*and*-*escape the flag (verified against gobwas/glob). Worth a note for future readers on the flip side: because the guarantee is precisely "matches all four probe shapes," a pattern that matches every real service name but requires ≥2 characters (e.g.??*) is not flagged, and single-char in-cluster Service names are legal DNS labels. That's deliberately the safe direction here (under-reject rather than fail a pod at boot), and it fits your "behavioural probe, not a proof of universal breadth" framing — so this is just documenting the boundary, not asking for a change. Adding more single-char probes wouldn't move it; only widening intent-detection would.