Privilege Escalation via token exchange namespace leakage
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)
+ })
+ }
+}