Skip to content

fix: reject match-all skip_hosts and unreachable routes - #839

Merged
huang195 merged 2 commits into
mainfrom
fix/skiphost-match-all-guard
Sep 1, 2026
Merged

fix: reject match-all skip_hosts and unreachable routes#839
huang195 merged 2 commits into
mainfrom
fix/skiphost-match-all-guard

Conversation

@huang195

@huang195 huang195 commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Three packages match a destination Host against operator-supplied patterns, each compiling glob.Compile(pattern, '.') itself and reasoning about breadth on its own:

package a match means
listener/skiphost bypass the pipeline and session recording
routing select token-exchange parameters
plugins/tokenbroker select broker parameters

That duplication is the root cause of everything below. This PR gives the behaviour one owner and fixes the two bugs the duplication hid.

1. skip_hosts accepted most spellings of match-all

skiphost.New's guard identified footgun patterns by string equality:

if trimmed == "*" || trimmed == "**" { reject }

It caught the two obvious spellings and nothing else. Under .-separated globs these are all equally broad and all passed:

pattern why it is broad
***, ****, longer runs a run of stars behaves as **
{**} super-star wrapped in braces
{*,**} alternation containing a super-star
?* one character then anything — any non-empty label

Any one of them in listener.skip_hosts exempted every outbound host from IBAC and token-exchange, which is exactly what the guard's doc comment says it prevents. Present under both glob v0.2.3 and v1.0.0, so unrelated to any pending bump — it is live on main today.

Now rejected by behaviour rather than spelling: compile the pattern, then reject it if it matches every short single-label Kubernetes service name, which is the shape that made bare * dangerous to begin with.

2. The two routers had no breadth check at all

They also deliberately do not copy skip_hosts' blanket rejection, because a match-all in a router is not a bypass — as the final route it is a legitimate explicit catch-all, equivalent to defaultAction.

The real hazard is first-match-wins: a broad early pattern silently swallows every route beneath it. A *** typo at the top of authproxy-routes, or a plain * (which matches every short in-cluster service name), kills every route below — and if the shadowing route is a passthrough, token exchange or brokering is silently off for hosts the operator explicitly listed.

So both routers reject unreachable routes instead. hostglob.Shadows is sound rather than complete — it reports only certain unreachability:

  • the earlier pattern is match-all, so nothing after it is reachable; or
  • the later pattern is a literal host the earlier pattern already matches.

A later wildcard shadowed by a narrower earlier wildcard (*.a.com after *.*.com) is not reported. Deciding that means deciding glob subsumption, and a false positive fails the pod at boot, so the check stays conservative.

Both routers additionally now reject an empty host pattern — a route whose host: key is missing from routes.yaml compiles into a pattern matching only an empty Host header, so it can never match real traffic — and both take the no-route path for an empty host rather than offering it to the patterns. A bare * matches the empty string under gobwas/glob, so an unset Host header would otherwise select a * route and mint or broker a token for a destination we cannot identify.

3. One owner: authlib/internal/hostglob

The separator, the breadth probes and the shadowing rule now live in one leaf package that depends on nothing inside authlib. Beyond fixing the above, it means the eventual glob v1 migration (blocked on OPA — see #829) lands in one file instead of three.

⚠️ Operational note — please read before merging

The production router is built from the authproxy-routes ConfigMap, so a live config containing a duplicate or shadowed route will now fail the pod at boot instead of silently ignoring that route.

Traffic behaviour is unchanged — a shadowed route was already dead — so this trades availability on upgrade for a loud signal. If that trade is not wanted, downgrading the shadowing check to a logged warning is a one-line change; say so and I will make it. The empty-host-pattern and skip_hosts checks are lower risk (no real config can rely on either).

Over-rejection is the risk that matters

A wrongly rejected pattern fails the pod at boot, so the dangerous direction here is being too aggressive. Pinned as accepted:

  • TestNew_AcceptsNonMatchAllWildcards — twelve operator-plausible skip_hosts wildcards
  • TestNewRouter_AcceptsTrailingCatchAll — match-all in the final position
  • TestNewRouter_AcceptsSingleStarBeforeFQDN* before an FQDN route, which stays reachable because * spans one label
  • TestNewBrokerRouter_AcceptsReachableRoutes — five genuinely-reachable route lists
  • TestMatchesEverySingleLabel_NarrowPatterns — eleven patterns that must not be flagged
  • plus the pre-existing TestNew_AcceptsLeadingStar

Test fixture changed

TestResolve_FirstMatchWins configured the host "service" twice — dead config, now rejected. Rewritten to prove the same property with two genuinely overlapping patterns (svc-* and *-prod) where neither route is unreachable, plus an assertion that the second route really is reachable. It also ignored NewRouter's error, which is why the new guard surfaced as a nil-pointer panic rather than a clean failure; it checks the error now.

Verification

  • go test ./... across all of authlib: pass
  • go build ./... && go vet ./... for every authbridge module (all but cmd/authbridge-cpex, which has no packages without -tags cpex and cannot link locally without libcpex_ffi): pass
  • golangci-lint run on internal/hostglob, routing, listener/skiphost: 0 issues
  • gofmt: clean

plugins/tokenbroker has 12 pre-existing golangci-lint findings in client/*_test.go, plugin_edge_test.go and plugin_testing.go — none in files this PR touches, and none introduced here.

Related: #837 pins the glob matching contract these three packages share; #829 is the blocked glob v1 bump that prompted the audit.

Assisted-By: Claude Code

New's guard existed to stop an operator pattern from silently disabling
outbound enforcement — skip_hosts bypasses the plugin pipeline AND session
recording for matched traffic — but it identified match-all patterns by
string equality:

	if trimmed == "*" || trimmed == "**" { reject }

That caught the two obvious spellings and nothing else. Under `.`-delimited
gobwas/glob these are all equally match-all and all sailed through:

	"***", "****", and any longer run of stars
	"{**}", "{*,**}"  — super-star wrapped in braces or alternation
	"?*"              — one character then anything, i.e. any non-empty label

Any one of them in listener.skip_hosts exempted every outbound host from
IBAC and token-exchange, which is exactly the outcome the guard's doc
comment says it prevents. Confirmed present under both glob v0.2.3 and
v1.0.0, so this is not a consequence of any pending bump.

Replaces the string comparison with a behavioural probe: compile the
pattern, then reject it if it matches every host in matchAllProbeHosts —
short single-label Kubernetes service names, the shape that made bare "*"
dangerous in the first place. A pattern that matches all of those is
match-all in practice however it is written.

Single-label probes suffice: anything matching everything matches these too,
and a pattern needing a separator or a fixed prefix/suffix ("*.*",
"*.svc.cluster.local", "service-*") is untouched. Over-rejection is the
failure mode that matters here — a rejected pattern fails the pod at boot —
so TestNew_AcceptsNonMatchAllWildcards pins twelve operator-plausible
wildcards as accepted, alongside the existing TestNew_AcceptsLeadingStar.

The match-all check necessarily moves after glob.Compile now, since it needs
a compiled pattern. Error text and the empty/whitespace and port guards are
unchanged.

Not addressed here: routing/router.go and plugins/tokenbroker/plugin.go
compile host patterns the same way and have no match-all guard at all. A
match-all there redirects rather than bypasses, so it is a different (and
milder) question — left as follow-up.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 requested a review from a team as a code owner September 1, 2026 13:03
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5f01c28c-4e58-4a04-8775-9661605b4c64

📥 Commits

Reviewing files that changed from the base of the PR and between 83383a0 and fc5e9f4.

📒 Files selected for processing (8)
  • authbridge/authlib/internal/hostglob/hostglob.go
  • authbridge/authlib/internal/hostglob/hostglob_test.go
  • authbridge/authlib/listener/skiphost/skiphost.go
  • authbridge/authlib/listener/skiphost/skiphost_test.go
  • authbridge/authlib/plugins/tokenbroker/plugin.go
  • authbridge/authlib/plugins/tokenbroker/router_guard_test.go
  • authbridge/authlib/routing/router.go
  • authbridge/authlib/routing/router_test.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Extends the skip_hosts guard to the other two consumers of host patterns,
and gives all three a single owner for the behaviour.

authlib/internal/hostglob is now the one place cortex compiles an
operator-supplied host pattern and decides whether one is dangerously broad.
Three packages each did this themselves — listener/skiphost, routing and
plugins/tokenbroker — which is precisely why skiphost's guard could compare
against the literal strings "*" and "**" and miss "***", "{**}", "{*,**}"
and "?*", and why the other two had no breadth check at all. It also means
the eventual glob v1 migration (blocked on OPA, see #829) lands in one file
instead of three.

The routers deliberately do NOT copy skip_hosts' blanket rejection. A
match-all there is not a bypass: as the final route it is a legitimate
explicit catch-all, equivalent to defaultAction. The hazard is
first-match-wins — a broad early pattern silently swallows every route
beneath it, and if that early route is a passthrough then token exchange or
brokering is off for hosts the operator explicitly listed.

So both routers now reject unreachable routes instead. hostglob.Shadows is
sound rather than complete: it reports only certain unreachability — the
earlier pattern is match-all, or the later pattern is a literal the earlier
one already matches. A later wildcard shadowed by a narrower earlier wildcard
is not reported, because deciding glob subsumption in general risks a false
positive, and a false positive fails the pod at boot.

Both routers also now reject an empty host pattern (a route whose `host:` key
is missing from routes.yaml matches only an empty Host header, never real
traffic), and both take the no-route path for an empty host rather than
offering it to the patterns — a bare "*" matches the empty string under
gobwas/glob, so an unset Host header would otherwise select a "*" route and
mint or broker a token for a destination we cannot identify.

OPERATIONAL NOTE: the production router is built from the authproxy-routes
ConfigMap, so a live config containing a duplicate or shadowed route will now
fail the pod at boot instead of silently ignoring that route. Traffic
behaviour is unchanged — a shadowed route was already dead — so this trades
availability on upgrade for a loud signal. Downgrading the shadowing check to
a warning is a one-line change if that trade is not wanted.

TestResolve_FirstMatchWins configured the host "service" twice, which is dead
config and now rejected; it was rewritten to prove the same property with two
genuinely overlapping patterns ("svc-*" and "*-prod") where neither route is
unreachable.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195 huang195 changed the title fix: reject every spelling of match-all in skip_hosts fix: reject match-all skip_hosts and unreachable routes Sep 1, 2026

@mrsabath mrsabath left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-reasoned security hardening. It closes a live IBAC/token-exchange bypass (match-all skip_hosts spellings beyond */**), consolidates three duplicated glob-compile sites into one leaf hostglob package, and adds unreachable-route + empty-host guards to both routers.

I verified the probe behaviour against gobwas/glob v0.2.3 directly: the guard catches even spellings the PR doesn't enumerate (*?, [a-z]*, ?**, {a,*}) and correctly leaves narrow patterns (*.*, otel-*, *-prod, ?) untouched. I also confirmed both empty-host branches in Router.Resolve and brokerRouter.resolve are byte-identical to their existing no-match fallthrough, so no traffic behaviour changes. hostglob.Shadows is soundly conservative exactly as documented.

The operational note is the right call and clearly stated: shadowed/duplicate routes in authproxy-routes now fail the pod at boot rather than being silently ignored, with a one-line rollback to a warning if the availability trade isn't wanted. Since a shadowed route was already dead, traffic is unchanged.

Areas reviewed: Go, security, tests
Commits: 2, both signed-off, both carry the correct Assisted-By: Claude (Anthropic AI) trailer
CI: all green

一石二鸟 (yi shi er niao) — one stone, two birds: you consolidated the duplication and killed two hidden bugs in the same motion. Nice work.

One non-blocking design note left inline.

// 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",

Copy link
Copy Markdown
Contributor

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.

@huang195
huang195 merged commit a197159 into main Sep 1, 2026
21 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 1, 2026
@huang195
huang195 deleted the fix/skiphost-match-all-guard branch September 1, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants