OAuth / OBO / M2M security audit — consolidated findings
Adversarially-verified audit of the OAuth surface across muster, mcp-oauth, and
mcp-kubernetes (token exchange, OBO, M2M workload exchange, K8s impersonation, spec
compliance). Tracking all actionable findings here. Each finding gets the same shape:
where → impact → fix. 12 refuted false-positives are listed at the bottom so we
don't re-investigate them.
Counts: 3 HIGH, 11 MEDIUM, plus LOW/INFO summarized. Findings span three repos;
mcp-oauth and mcp-kubernetes items are cross-linked from here for one place to triage.
HIGH
H1 — SSO email claim used as Impersonate-User without email_verified enforcement
- Repo: mcp-kubernetes (root cause shared with mcp-oauth SSO path)
- Where:
internal/federation/kubeconfig.go:641 & :732; internal/federation/manager.go:866; internal/server/oauth_http.go:1078-1085. mcp-oauth v0.7.1 server/sso.go:150-168 copies EmailVerified but never gates on it. internal/federation/validation.go:141-189 validates email format only.
- Impact: A federated IdP emitting an unverified or attacker-controlled
email claim lets the holder impersonate any K8s user whose RBAC is keyed on that email. Direct privilege escalation (email: cluster-admin@corp → become that user).
- Fix: Enforce
email_verified == true before using email as the impersonated user, or key impersonation on stable sub. Add an SSO-path allowedClaims re-check mirroring the existing M2M sub re-verification. Reject tokens with email present but email_verified absent/false.
H2 — Admin session-detail JSON serializes full signed bearer tokens
- Repo: muster
- Where:
internal/admin/handlers.go:71-74; internal/admin/view.go:24 & :66-69 (SessionToken.Raw has no json:"-"); populated at internal/aggregator/admin_adapter.go:209-244. HTML path strips the signature via internal/admin/jwt.go:16-22, but the JSON branch embeds *SessionDetail and emits the full JWT (header.payload.signature), including RFC 8693 exchanged access tokens.
- Impact: Anyone reaching the admin JSON endpoint gets live, replayable bearer tokens for every session, including downstream OBO tokens. Token theft → full impersonation of every active user/agent.
- Fix: Tag
SessionToken.Raw with json:"-"; return an explicit DTO from the JSON handler instead of serializing the internal struct; regression test asserting no .-segmented JWT appears in the JSON body.
H3 — Admin listener exposes all sessions' tokens with no authn/authz
- Repo: muster
- Where:
internal/admin/server.go:109-131 (bare http.ServeMux, no auth middleware); config internal/config/types.go:58-67; defaults internal/app/services.go:256-262 (default 127.0.0.1:9999, off by default, BindAddress configurable).
- Impact: With H2, the admin plane is an unauthenticated token-exfiltration endpoint. Default loopback limits blast radius, but any non-loopback
BindAddress (or any local process / sidecar) reads every session's tokens. No defense in depth.
- Fix: Require authn+authz on the admin listener even on loopback; refuse to start with a non-loopback
BindAddress unless auth is configured.
MEDIUM
M1 — Non-strict downstream-OAuth fallback to privileged SA
- Repo: mcp-kubernetes —
internal/k8s/context.go:154-186
- Impact: When downstream identity is absent and strict mode is off, the client falls back to the server's own privileged ServiceAccount, executing user requests with ambient cluster privileges.
- Fix: Default to strict; fail closed on missing identity. Log+metric the fallback when explicitly opted in.
M2 — MC restriction guard only in GetClusterClient, not K8sClientForContext
- Repo: mcp-kubernetes — guard at
internal/federation/federation.go:194-198, absent on internal/k8s/context.go:130-148
- Impact: Management-cluster restriction enforced on one client path but not the other; the unguarded path bypasses it.
- Fix: Hoist the MC guard into a shared helper both paths call; test the context path rejects MC when restricted.
M3 — Empty AllowedTargetClusters = allow-all (includes MC)
- Repo: mcp-kubernetes —
internal/federation/impersonation.go:19-21
- Impact: Empty list interpreted as "any cluster allowed," including the management cluster. Open-by-omission.
- Fix: Treat empty as deny-all (or require explicit
*); fail closed.
M4 — serve.go sub-pattern check insufficient
- Repo: mcp-kubernetes —
cmd/serve.go:610-614
- Impact: Startup
sub pattern validation is weaker than the runtime matcher; configs that look constrained at startup admit broader subjects at request time.
- Fix: Share one matcher between startup validation and runtime enforcement.
M5 — OBO actor binding advisory only — any allowed actor → any allowed human
- Repo: mcp-oauth —
rbac.yaml:606-617, internal/server/oauth_http.go:1002-1035, impersonation_client.go:96-121
- Impact: Actor and subject are authorized by independent RBAC rules with no binding. Any allowlisted actor can impersonate any allowlisted human; the
act/subject pairing is unconstrained, defeating delegation scoping.
- Fix: Enforce pairwise actor→subject binding (
ActorDelegationPolicy), not two independent allowlists.
M6 — AllowedClaims nil = no restriction → trusted issuer authorizes any subject
- Repo: mcp-oauth —
subject_validator.go:188-203
- Impact: A trusted issuer with
AllowedClaims unset accepts any subject. Trust in the issuer becomes trust in every principal it can mint.
- Fix: Require an explicit claim constraint for trusted issuers, or fail closed when nil.
M7 — AllowPrivateIPJWKS client follows redirects with no SSRF guard
- Repo: mcp-oauth —
providers/oidc/validation.go:359-382
- Impact: With
AllowPrivateIPJWKS enabled, JWKS fetches follow redirects on the default client (no custom dialer, no CheckRedirect). A malicious issuer URL can redirect into internal services (SSRF).
- Fix: Set
CheckRedirect to re-apply the private-IP policy per hop; use a dialer enforcing the IP allowlist post-DNS-resolution.
M8 — Empty AllowedScopes grants requested scope verbatim
- Repo: mcp-oauth —
token_exchange.go:295-304
- Impact: Empty
AllowedScopes returns whatever scope the client requested. Open-by-omission scope escalation.
- Fix: Empty = grant no extra scope (or require explicit allowlist); never echo requested scope unchecked.
M9 — audience/resource not allowlist-constrained on local/workload mint
- Repo: mcp-oauth —
token_exchange_broker.go:230 & :317, local_mint_exchanger.go:64-67
- Impact: The
audience/resource parameter is unconstrained when minting local/workload tokens; a caller can mint tokens for arbitrary audiences (RFC 8707 misuse → downstream audience confusion).
- Fix: Constrain mintable audiences/resources to a configured allowlist per client/grant.
M10 — Token revocation has no client-ownership check (RFC 7009 violation)
- Repo: mcp-oauth —
revoke.go:53, jwt.go:336, handler/revoke.go:48
- Impact: Revocation doesn't verify the token belongs to the authenticated client (RFC 7009 §2.1). Any authenticated client can revoke another client's tokens (DoS / forced re-auth).
- Fix: Verify the token's owner/
client_id matches the authenticated caller before revoking.
M11 — Empty AllowedTargetClusters UX footgun (companion to M3)
- Repo: mcp-kubernetes —
internal/federation/impersonation.go:19-21
- Impact: "Leave empty" default reads as "nothing configured yet" but means "all clusters." Operators expect deny, get allow-all.
- Fix: Make the empty-default loud (startup warning + docs), or change semantics to deny-all.
LOW / INFO (summary)
LOW (38) notable items:
- mcp-oauth: RFC 7592 client-update PUT skips redirect-URI re-validation; JWT-mode revocation fails open on nil store; RFC 9068 token-exchange tokens omit
client_id; AcceptedTypHeaders = ["", "JWT"] disables the at+jwt typ gate; several open-by-omission defaults.
- mcp-kubernetes: in-cluster impersonation cache key omits issuer (cross-issuer cache collision); email-format-only validation (H1 root cause); strict-mode default/logging gaps.
- muster: trusted-issuer forwarding hardening; admin-plane logging may include token-adjacent fields.
INFO (45) are confirmed-safe notes documenting correct guards (alg allowlists exclude none/HMAC; PKCE enforced; expired-token gate fail-closed; startup hard-fail on empty workload issuer; intersection-of-two-engines sub matching is fail-closed).
Suggested remediation order
- H2 + H3 (muster admin plane) — one PR:
json:"-" on Raw, DTO, auth on listener, refuse non-loopback bind without auth. Highest impact, smallest change.
- H1 (mcp-kubernetes) — enforce
email_verified / key on sub; add SSO-path claim re-check.
- M3/M11, M6, M8, M9 — flip open-by-omission defaults to fail-closed.
- M5 — make OBO delegation pairwise.
- M10 — RFC 7009 ownership check on revoke.
- M1, M2, M4, M7 — close fallback / guard-asymmetry / SSRF gaps.
Refuted (false positives — do not re-investigate)
| Claimed |
Area |
Why refuted |
| HIGH |
oauth-aud-iss-exp |
Empty AllowedAudiences falls back to resource identifier, not "any." |
| LOW |
oauth-client-auth-m2m |
bcrypt timing oracle guarded by random client_id check. |
| HIGH |
muster-trusted-issuer |
Raw bearer forwarded as upstream ID token is documented SSO design, not aud confusion. |
| MED |
muster-trusted-issuer |
ErrTrustedAudienceMismatch fallback does not widen acceptance. |
| MED |
muster-trusted-issuer |
Unparseable-exp: IsExpired returns true, fail-closed. |
| LOW |
muster-trusted-issuer |
Emailless SA identities gated by issuer+audience+claims allowlists. |
| HIGH |
muster-broker-delegation |
Empty workload Issuer matches nothing + startup hard-fail. |
| HIGH |
k8s-obo-impersonation |
Federation OBO actor-drop: path partly unimplemented on this branch. |
| MED |
k8s-obo-impersonation |
Trailing-* sub match is fail-closed intersection of two engines. |
| HIGH |
k8s-m2m-sa-mapping |
Glob-engine mismatch tightens, not widens. |
| LOW/HIGH |
xrepo |
Empty AllowedAudiences = "any audience": falls back to resource id. |
OAuth / OBO / M2M security audit — consolidated findings
Adversarially-verified audit of the OAuth surface across
muster,mcp-oauth, andmcp-kubernetes(token exchange, OBO, M2M workload exchange, K8s impersonation, speccompliance). Tracking all actionable findings here. Each finding gets the same shape:
where → impact → fix. 12 refuted false-positives are listed at the bottom so we
don't re-investigate them.
Counts: 3 HIGH, 11 MEDIUM, plus LOW/INFO summarized. Findings span three repos;
mcp-oauth and mcp-kubernetes items are cross-linked from here for one place to triage.
HIGH
H1 — SSO email claim used as
Impersonate-Userwithoutemail_verifiedenforcementinternal/federation/kubeconfig.go:641&:732;internal/federation/manager.go:866;internal/server/oauth_http.go:1078-1085. mcp-oauth v0.7.1server/sso.go:150-168copiesEmailVerifiedbut never gates on it.internal/federation/validation.go:141-189validates email format only.emailclaim lets the holder impersonate any K8s user whose RBAC is keyed on that email. Direct privilege escalation (email: cluster-admin@corp→ become that user).email_verified == truebefore usingemailas the impersonated user, or key impersonation on stablesub. Add an SSO-pathallowedClaimsre-check mirroring the existing M2Msubre-verification. Reject tokens withemailpresent butemail_verifiedabsent/false.H2 — Admin session-detail JSON serializes full signed bearer tokens
internal/admin/handlers.go:71-74;internal/admin/view.go:24&:66-69(SessionToken.Rawhas nojson:"-"); populated atinternal/aggregator/admin_adapter.go:209-244. HTML path strips the signature viainternal/admin/jwt.go:16-22, but the JSON branch embeds*SessionDetailand emits the full JWT (header.payload.signature), including RFC 8693 exchanged access tokens.SessionToken.Rawwithjson:"-"; return an explicit DTO from the JSON handler instead of serializing the internal struct; regression test asserting no.-segmented JWT appears in the JSON body.H3 — Admin listener exposes all sessions' tokens with no authn/authz
internal/admin/server.go:109-131(barehttp.ServeMux, no auth middleware); configinternal/config/types.go:58-67; defaultsinternal/app/services.go:256-262(default127.0.0.1:9999, off by default,BindAddressconfigurable).BindAddress(or any local process / sidecar) reads every session's tokens. No defense in depth.BindAddressunless auth is configured.MEDIUM
M1 — Non-strict downstream-OAuth fallback to privileged SA
internal/k8s/context.go:154-186M2 — MC restriction guard only in
GetClusterClient, notK8sClientForContextinternal/federation/federation.go:194-198, absent oninternal/k8s/context.go:130-148M3 — Empty
AllowedTargetClusters= allow-all (includes MC)internal/federation/impersonation.go:19-21*); fail closed.M4 —
serve.gosub-pattern check insufficientcmd/serve.go:610-614subpattern validation is weaker than the runtime matcher; configs that look constrained at startup admit broader subjects at request time.M5 — OBO actor binding advisory only — any allowed actor → any allowed human
rbac.yaml:606-617,internal/server/oauth_http.go:1002-1035,impersonation_client.go:96-121act/subject pairing is unconstrained, defeating delegation scoping.ActorDelegationPolicy), not two independent allowlists.M6 —
AllowedClaimsnil = no restriction → trusted issuer authorizes any subjectsubject_validator.go:188-203AllowedClaimsunset accepts any subject. Trust in the issuer becomes trust in every principal it can mint.M7 —
AllowPrivateIPJWKSclient follows redirects with no SSRF guardproviders/oidc/validation.go:359-382AllowPrivateIPJWKSenabled, JWKS fetches follow redirects on the default client (no custom dialer, noCheckRedirect). A malicious issuer URL can redirect into internal services (SSRF).CheckRedirectto re-apply the private-IP policy per hop; use a dialer enforcing the IP allowlist post-DNS-resolution.M8 — Empty
AllowedScopesgrants requested scope verbatimtoken_exchange.go:295-304AllowedScopesreturns whatever scope the client requested. Open-by-omission scope escalation.M9 — audience/resource not allowlist-constrained on local/workload mint
token_exchange_broker.go:230&:317,local_mint_exchanger.go:64-67audience/resourceparameter is unconstrained when minting local/workload tokens; a caller can mint tokens for arbitrary audiences (RFC 8707 misuse → downstream audience confusion).M10 — Token revocation has no client-ownership check (RFC 7009 violation)
revoke.go:53,jwt.go:336,handler/revoke.go:48client_idmatches the authenticated caller before revoking.M11 — Empty
AllowedTargetClustersUX footgun (companion to M3)internal/federation/impersonation.go:19-21LOW / INFO (summary)
LOW (38) notable items:
client_id;AcceptedTypHeaders = ["", "JWT"]disables theat+jwttyp gate; several open-by-omission defaults.INFO (45) are confirmed-safe notes documenting correct guards (alg allowlists exclude
none/HMAC; PKCE enforced; expired-token gate fail-closed; startup hard-fail on empty workload issuer; intersection-of-two-engines sub matching is fail-closed).Suggested remediation order
json:"-"onRaw, DTO, auth on listener, refuse non-loopback bind without auth. Highest impact, smallest change.email_verified/ key onsub; add SSO-path claim re-check.Refuted (false positives — do not re-investigate)
AllowedAudiencesfalls back to resource identifier, not "any."client_idcheck.ErrTrustedAudienceMismatchfallback does not widen acceptance.exp:IsExpiredreturns true, fail-closed.Issuermatches nothing + startup hard-fail.*sub match is fail-closed intersection of two engines.AllowedAudiences= "any audience": falls back to resource id.