Privilege Escalation via token exchange namespace leakage

HIGH
grafana/grafana
Commit: 438fe0fac824
Affected: <=12.4.0
2026-07-15 22:52 UTC

Description

The commit tightens the token exchange namespace scoping used by the annotation API. Previously the token exchange requests could be issued with a wildcard Namespace ('*'), enabling tokens to be exchanged with broad, cross-namespace permissions. The fix introduces a NamespaceMapper derived from the requester context (stack namespace when a stack is present, or org/default namespaces otherwise) and uses it to set the TokenExchangeRequest.Namespace, thereby scoping token exchange to the requester’s stack/org context. This reduces the risk of privilege escalation via token exchange to resources outside the requester’s namespace. The change also updates the REST client construction to pass the Mapper to the token exchange wrapper and adds tests validating the namespace derivation logic.

Proof of Concept

PoC (conceptual): - Context: Annotations API uses a TokenExchanger to obtain a short-lived token for talking to the annotation server on behalf of the requester. Before this fix, the Namespace field in the TokenExchangeRequest could be '*' (wildcard). - Vulnerability demonstration (pre-fix behavior): - A user with access to a specific stack or org triggers a token exchange. - The Exchange call is made with Namespace: '*', so the issued token is valid across all namespaces. - With that token, the user can access resources or perform actions in namespaces outside their own scope, enabling privilege escalation. - PoC steps (conceptual): 1) Set up Grafana with the annotation API and a requester that has StackID = "123" and OrgID = 1. 2) The Annotation API client makes a token exchange call to obtain a token for the annotation server. 3) Observe that the TokenExchangeRequest.Namespace is '*' (pre-fix) and the exchanged token potentially carries broad, cross-namespace permissions. 4) Use the obtained token to perform an operation (via the annotation server) that would normally be restricted to the requester’s namespace, illustrating privilege escalation. After the fix (post-fix), the Namespace value is derived from the requester context: - If StackID is set (e.g., 123): Namespace = "stacks-123" - Else if OrgID == 1: Namespace = "default" - Else: Namespace = "org-<OrgID>" This constrains the token to the requester’s scope and prevents cross-namespace privilege escalation. - Minimal code-oriented demonstration (using the test harness from the patch): - Before fix: exchanger.gotRequest.Namespace would be "*" for a stack-scoped requester. - After fix: exchanger.gotRequest.Namespace reflects the mapped value, e.g., "stacks-123", "default", or "org-7" depending on StackID/OrgID. Note: The PoC here is conceptual and aligns with the unit-test additions in the commit, which validate that the Namespace is properly derived from the requester context rather than using a wildcard.

Commit Details

Author: Craig O'Donnell

Date: 2026-07-15 22:17 UTC

Message:

fix(annotations): scope proxy token exchange to stack namespace (#128524)

Triage Assessment

Vulnerability Type: Privilege Escalation

Confidence: HIGH

Reasoning:

The patch tightens the namespace scoping for token exchanges in the annotation API by deriving the namespace from the requester stack/org context instead of using a wildcard. This prevents tokens from being exchanged with overly broad permissions, reducing the risk of unauthorized access (privilege escalation) through token exchange.

Verification Assessment

Vulnerability Type: Privilege Escalation via token exchange namespace leakage

Confidence: HIGH

Affected Versions: <=12.4.0

Code Diff

diff --git a/pkg/services/annotations/annotationsapi/api_client.go b/pkg/services/annotations/annotationsapi/api_client.go index 15f58e5cddf3d..364d9db98d1f2 100644 --- a/pkg/services/annotations/annotationsapi/api_client.go +++ b/pkg/services/annotations/annotationsapi/api_client.go @@ -11,6 +11,7 @@ import ( "sync" authnlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/setting" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -48,11 +49,12 @@ func newAnnotationAPIClient(cfg *setting.Cfg, userSvc user.Service, exchanger au return nil } - restCfg := buildRESTConfig(url, exchanger, cfg.Env == setting.Dev) + nsMapper := request.GetNamespaceMapper(cfg) + restCfg := buildRESTConfig(url, exchanger, nsMapper, cfg.Env == setting.Dev) return &annotationAPIClient{ k8sClient: client.NewK8sHandler( - request.GetNamespaceMapper(cfg), + nsMapper, annotationV0.AnnotationKind().GroupVersionResource(), func(_ context.Context) (*rest.Config, error) { return restCfg, nil }, userSvc, @@ -237,10 +239,10 @@ func newTokenExchangeClient(token, tokenExchangeURL string, allowInsecure bool) return tc, nil } -func buildRESTConfig(url string, exchanger authnlib.TokenExchanger, allowInsecure bool) *rest.Config { +func buildRESTConfig(url string, exchanger authnlib.TokenExchanger, nsMapper request.NamespaceMapper, allowInsecure bool) *rest.Config { return &rest.Config{ Host: url, - WrapTransport: newBearerTokenExchangeWrapper(exchanger), + WrapTransport: newBearerTokenExchangeWrapper(exchanger, nsMapper), TLSClientConfig: rest.TLSClientConfig{ Insecure: allowInsecure, }, @@ -249,13 +251,20 @@ func buildRESTConfig(url string, exchanger authnlib.TokenExchanger, allowInsecur type bearerTokenExchangeRT struct { exchanger authnlib.TokenExchanger + nsMapper request.NamespaceMapper next http.RoundTripper } func (rt *bearerTokenExchangeRT) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := rt.exchanger.Exchange(req.Context(), authnlib.TokenExchangeRequest{ + ctx := req.Context() + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, fmt.Errorf("resolving requester for token exchange: %w", err) + } + + resp, err := rt.exchanger.Exchange(ctx, authnlib.TokenExchangeRequest{ Audiences: []string{annotationServerAudience}, - Namespace: "*", + Namespace: rt.nsMapper(requester.GetOrgID()), }) if err != nil { return nil, fmt.Errorf("exchanging token: %w", err) @@ -265,8 +274,8 @@ func (rt *bearerTokenExchangeRT) RoundTrip(req *http.Request) (*http.Response, e return rt.next.RoundTrip(req) } -func newBearerTokenExchangeWrapper(exchanger authnlib.TokenExchanger) func(http.RoundTripper) http.RoundTripper { +func newBearerTokenExchangeWrapper(exchanger authnlib.TokenExchanger, nsMapper request.NamespaceMapper) func(http.RoundTripper) http.RoundTripper { return func(rt http.RoundTripper) http.RoundTripper { - return &bearerTokenExchangeRT{exchanger: exchanger, next: rt} + return &bearerTokenExchangeRT{exchanger: exchanger, nsMapper: nsMapper, next: rt} } } diff --git a/pkg/services/annotations/annotationsapi/api_client_test.go b/pkg/services/annotations/annotationsapi/api_client_test.go new file mode 100644 index 0000000000000..287e18fa60faa --- /dev/null +++ b/pkg/services/annotations/annotationsapi/api_client_test.go @@ -0,0 +1,102 @@ +package annotationsapi + +import ( + "context" + "net/http" + "testing" + + authnlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTokenExchanger struct { + gotRequest authnlib.TokenExchangeRequest +} + +func (f *fakeTokenExchanger) Exchange(_ context.Context, r authnlib.TokenExchangeRequest) (*authnlib.TokenExchangeResponse, error) { + f.gotRequest = r + return &authnlib.TokenExchangeResponse{Token: "signed-token"}, nil +} + +type stubRoundTripper struct { + gotToken string +} + +func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + s.gotToken = req.Header.Get("X-Access-Token") + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestNewAnnotationAPIClient_TokenExchange(t *testing.T) { + tests := []struct { + name string + stackID string + requester *identity.StaticRequester + wantNamespace string + wantErr bool + }{ + { + name: "scopes to stack namespace when stackID is set", + stackID: "123", + requester: &identity.StaticRequester{OrgID: 1}, + wantNamespace: "stacks-123", + }, + { + name: "scopes to the default namespace when stackID is not set and orgID is 1", + requester: &identity.StaticRequester{OrgID: 1}, + wantNamespace: "default", + }, + { + name: "scopes to the org namespace when stackID is not set and orgID is not 1", + requester: &identity.StaticRequester{OrgID: 7}, + wantNamespace: "org-7", + }, + { + name: "fails when the requester is missing", + stackID: "123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &setting.Cfg{ + StackID: tt.stackID, + AnnotationAppPlatform: setting.AnnotationAppPlatformSettings{ + APIServerURL: "https://annotation.cluster.local", + }, + } + + exchanger := &fakeTokenExchanger{} + c := newAnnotationAPIClient(cfg, nil, exchanger) + require.NotNil(t, c) + require.NotNil(t, c.restCfg.WrapTransport) + + next := &stubRoundTripper{} + rt := c.restCfg.WrapTransport(next) + + req, err := http.NewRequest(http.MethodPost, cfg.AnnotationAppPlatform.APIServerURL+"/annotations", http.NoBody) + require.NoError(t, err) + if tt.requester != nil { + req = req.WithContext(identity.WithRequester(req.Context(), tt.requester)) + } + + resp, err := rt.RoundTrip(req) + if tt.wantErr { + require.Error(t, err) + assert.Empty(t, next.gotToken) + return + } + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Equal(t, tt.wantNamespace, exchanger.gotRequest.Namespace) + assert.Equal(t, []string{annotationServerAudience}, exchanger.gotRequest.Audiences) + assert.Equal(t, "signed-token", next.gotToken) + }) + } +}
← Back to Alerts View on GitHub →