Skip to content

OAuth/OBO/M2M security audit — consolidated findings (3 HIGH, 11 MEDIUM) #856

Description

@QuentinBisson

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

  1. H2 + H3 (muster admin plane) — one PR: json:"-" on Raw, DTO, auth on listener, refuse non-loopback bind without auth. Highest impact, smallest change.
  2. H1 (mcp-kubernetes) — enforce email_verified / key on sub; add SSO-path claim re-check.
  3. M3/M11, M6, M8, M9 — flip open-by-omission defaults to fail-closed.
  4. M5 — make OBO delegation pairwise.
  5. M10 — RFC 7009 ownership check on revoke.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions