Input Validation

MEDIUM
grafana/grafana
Commit: 75c7f8210263
Affected: < 12.4.0
2026-04-23 09:26 UTC

Description

The commit hardens input validation on the service account token APIs (create, delete, list) by adding field validation for the token-related queries and making UID mandatory in the Go layer. Previously, inputs to these endpoints (notably UID in paths/filters and token request bodies) could be malformed or omitted, increasing the risk of token leakage, misassociation, or improper authorization when managing tokens for service accounts. The change moves related types and validates fields to ensure tokens are created/listed/deleted only for explicitly identified service accounts, reducing the attack surface around token management. In short, this is a security-hardening change that fixes an input validation gap in token APIs rather than a pure refactor or test-only change.

Proof of Concept

Proof-of-concept (describes pre-fix vulnerability and post-fix expectation): Assumptions: - Grafana IAM service exposes REST endpoints for service account tokens under a service account identifier (UID). - An attacker with valid admin/user credentials can call token APIs. Pre-fix vulnerability (demonstrative, shows potential leakage/abuse): 1) List tokens without UID (or with an empty/invalid UID): curl -s -X GET "https://grafana.example/api/iam/v0alpha1/serviceaccounts/tokens?limit=100" \ -H "Authorization: Bearer <TOKEN>" Expected outcome (before fix): Response could include tokens for multiple or all service accounts, leaking sensitive token data (token strings, names, expiry). 2) Create a token without binding to a specific UID (or with a malformed body): curl -s -X POST "https://grafana.example/api/iam/v0alpha1/serviceaccounts/tokens" \ -H "Authorization: Bearer <TOKEN>" \ -d '{"tokenName":"malicious-token","expiresInSeconds":3600}' Expected outcome (before fix): Token created under an unintended/unspecified service account, potentially enabling misuse. Post-fix expectation (with UID validation implemented): - The API requires a valid UID and validates it in the Go layer for List/Create/Delete operations. - Requests without UID or with invalid UID fail with 400/403 and do not leak or allow misassignment of tokens. Post-fix usage examples (expected correct usage): 1) List tokens for a specific service account: curl -s -X GET "https://grafana.example/api/iam/v0alpha1/serviceaccounts/{uid}/tokens?limit=100" \ -H "Authorization: Bearer <TOKEN>" 2) Create a token for a specific service account with explicit body: curl -s -X POST "https://grafana.example/api/iam/v0alpha1/serviceaccounts/{uid}/tokens" \ -H "Authorization: Bearer <TOKEN>" \ -d '{"tokenName":"new-token","expiresInSeconds":3600}' Expected result: Only tokens associated with the provided UID are returned/created/deleted; invalid or missing UID yields an error.

Commit Details

Author: Misi

Date: 2026-04-23 08:04 UTC

Message:

IAM: Service account token APIs with legacy support (#122327) * Work in progress SAT API with legacy support * Fix * fixes * Cleanup, change types, align authorizer * Regenerate openapi-related files * Other improvements * Integration tests + aligh authorizer to legacy * Clean up * More tests and validations * OpenAPI fixes, regen * Cleanup, chores * Cleanup * Small fix * Address feedback, add validation, tracing, and logging to service account token APIs - Add field validation to createServiceAccountTokenQuery, deleteServiceAccountTokenQuery, and listServiceAccountTokensQuery Validate() methods - Move ListServiceAccountTokens and related types from service_account.go to service_account_token.go for better code organization - Remove optional UID filter from service_account_tokens_query.sql (UID is now validated as mandatory in the Go layer) - Add OpenTelemetry spans to all token REST handlers (get, list, create, delete) - Add structured error logging via ctxLogger on all error paths - Extract saTokenGroupResource as a shared var to avoid repeated inline literals * Move the schema to cue

Triage Assessment

Vulnerability Type: Input Validation

Confidence: MEDIUM

Reasoning:

Commit adds input validation for service account token APIs (create, delete, list) and makes UID mandatory in the Go layer, reducing risk of malformed inputs or authorization/token misuse. This is a security-related hardening to token management APIs, not a pure refactor or docs change.

Verification Assessment

Vulnerability Type: Input Validation

Confidence: MEDIUM

Affected Versions: < 12.4.0

Code Diff

diff --git a/apps/iam/kinds/serviceaccount.cue b/apps/iam/kinds/serviceaccount.cue index f5589b5d8c609..cb3091edbd454 100644 --- a/apps/iam/kinds/serviceaccount.cue +++ b/apps/iam/kinds/serviceaccount.cue @@ -17,4 +17,62 @@ serviceaccountv0alpha1: serviceaccountKind & { schema: { spec: v0alpha1.ServiceAccountSpec } + routes: { + "/tokens": { + "GET": { + name: "listServiceAccountTokens" + request: { + query: { + limit?: int64 + continue?: string + } + } + response: { + items: [...#Token] + continue: string + } + } + } + "/tokens/{tokenName}": { + "GET": { + name: "getServiceAccountToken" + response: { + body: #Token + } + } + } + "/tokens": { + "POST": { + name: "createServiceAccountToken" + request: { + body: { + tokenName: string + expiresInSeconds: int64 + } + } + response: { + token: string + serviceAccountTokenName: string + expires: int64 + } + } + } + "/tokens/{tokenName}": { + "DELETE": { + name: "deleteServiceAccountToken" + response: { + message: string + } + } + } + } +} + +#Token: { + title: string + revoked: bool + expires: int64 + created: int64 + updated: int64 + lastUsed: int64 } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/register.go b/apps/iam/pkg/apis/iam/v0alpha1/register.go index 00e6f7753d235..081c80f6b377c 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/register.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/register.go @@ -373,9 +373,12 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error { &GetTeamGroupsResponse{}, &GetTeamMembersResponse{}, &GetUserTeamsResponse{}, + &ListServiceAccountTokensResponse{}, + &GetServiceAccountTokenResponse{}, + &CreateServiceAccountTokenResponse{}, + &DeleteServiceAccountTokenResponse{}, // For now these are registered in pkg/apis/iam/v0alpha1/register.go // &UserTeamList{}, - // &ServiceAccountTokenList{}, // &DisplayList{}, // &SSOSetting{}, // &SSOSettingList{}, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_client_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_client_gen.go index 0ce1616daa343..77a7b657d7d05 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_client_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_client_gen.go @@ -1,7 +1,13 @@ package v0alpha1 import ( + "bytes" "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" "github.com/grafana/grafana-app-sdk/resource" ) @@ -78,3 +84,96 @@ func (c *ServiceAccountClient) Patch(ctx context.Context, identifier resource.Id func (c *ServiceAccountClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { return c.client.Delete(ctx, identifier, opts) } + +type DeleteServiceAccountTokenRequest struct { + Headers http.Header +} + +func (c *ServiceAccountClient) DeleteServiceAccountToken(ctx context.Context, identifier resource.Identifier, request DeleteServiceAccountTokenRequest) (*DeleteServiceAccountTokenResponse, error) { + resp, err := c.client.SubresourceRequest(ctx, identifier, resource.CustomRouteRequestOptions{ + Path: "/tokens/{tokenName}", + Verb: "DELETE", + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := DeleteServiceAccountTokenResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into DeleteServiceAccountTokenResponse: %w", err) + } + return &cast, nil +} + +type GetServiceAccountTokenRequest struct { + Headers http.Header +} + +func (c *ServiceAccountClient) GetServiceAccountToken(ctx context.Context, identifier resource.Identifier, request GetServiceAccountTokenRequest) (*GetServiceAccountTokenResponse, error) { + resp, err := c.client.SubresourceRequest(ctx, identifier, resource.CustomRouteRequestOptions{ + Path: "/tokens/{tokenName}", + Verb: "GET", + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := GetServiceAccountTokenResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into GetServiceAccountTokenResponse: %w", err) + } + return &cast, nil +} + +type ListServiceAccountTokensRequest struct { + Params ListServiceAccountTokensRequestParams + Headers http.Header +} + +func (c *ServiceAccountClient) ListServiceAccountTokens(ctx context.Context, identifier resource.Identifier, request ListServiceAccountTokensRequest) (*ListServiceAccountTokensResponse, error) { + params := url.Values{} + resp, err := c.client.SubresourceRequest(ctx, identifier, resource.CustomRouteRequestOptions{ + Path: "/tokens", + Verb: "GET", + Query: params, + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := ListServiceAccountTokensResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into ListServiceAccountTokensResponse: %w", err) + } + return &cast, nil +} + +type CreateServiceAccountTokenRequest struct { + Body CreateServiceAccountTokenRequestBody + Headers http.Header +} + +func (c *ServiceAccountClient) CreateServiceAccountToken(ctx context.Context, identifier resource.Identifier, request CreateServiceAccountTokenRequest) (*CreateServiceAccountTokenResponse, error) { + body, err := json.Marshal(request.Body) + if err != nil { + return nil, fmt.Errorf("unable to marshal body to JSON: %w", err) + } + resp, err := c.client.SubresourceRequest(ctx, identifier, resource.CustomRouteRequestOptions{ + Path: "/tokens", + Verb: "POST", + Body: io.NopCloser(bytes.NewReader(body)), + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := CreateServiceAccountTokenResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into CreateServiceAccountTokenResponse: %w", err) + } + return &cast, nil +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_request_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_request_body_types_gen.go new file mode 100644 index 0000000000000..e9b726813fccb --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_request_body_types_gen.go @@ -0,0 +1,18 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +type CreateServiceAccountTokenRequestBody struct { + TokenName string `json:"tokenName"` + ExpiresInSeconds int64 `json:"expiresInSeconds"` +} + +// NewCreateServiceAccountTokenRequestBody creates a new CreateServiceAccountTokenRequestBody object. +func NewCreateServiceAccountTokenRequestBody() *CreateServiceAccountTokenRequestBody { + return &CreateServiceAccountTokenRequestBody{} +} + +// OpenAPIModelName returns the OpenAPI model name for CreateServiceAccountTokenRequestBody. +func (CreateServiceAccountTokenRequestBody) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.CreateServiceAccountTokenRequestBody" +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_body_types_gen.go new file mode 100644 index 0000000000000..4172748cc2d4f --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_body_types_gen.go @@ -0,0 +1,20 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type CreateServiceAccountTokenBody struct { + Token string `json:"token"` + ServiceAccountTokenName string `json:"serviceAccountTokenName"` + Expires int64 `json:"expires"` +} + +// NewCreateServiceAccountTokenBody creates a new CreateServiceAccountTokenBody object. +func NewCreateServiceAccountTokenBody() *CreateServiceAccountTokenBody { + return &CreateServiceAccountTokenBody{} +} + +// OpenAPIModelName returns the OpenAPI model name for CreateServiceAccountTokenBody. +func (CreateServiceAccountTokenBody) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.CreateServiceAccountTokenBody" +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_object_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_object_types_gen.go new file mode 100644 index 0000000000000..e532b8efbd464 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_createserviceaccounttoken_response_object_types_gen.go @@ -0,0 +1,41 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +k8s:openapi-gen=true +type CreateServiceAccountTokenResponse struct { + metav1.TypeMeta `json:",inline"` + CreateServiceAccountTokenBody `json:",inline"` +} + +func NewCreateServiceAccountTokenResponse() *CreateServiceAccountTokenResponse { + return &CreateServiceAccountTokenResponse{} +} + +func (t *CreateServiceAccountTokenBody) DeepCopyInto(dst *CreateServiceAccountTokenBody) { + _ = resource.CopyObjectInto(dst, t) +} + +func (o *CreateServiceAccountTokenResponse) DeepCopyObject() runtime.Object { + dst := NewCreateServiceAccountTokenResponse() + o.DeepCopyInto(dst) + return dst +} + +func (o *CreateServiceAccountTokenResponse) DeepCopyInto(dst *CreateServiceAccountTokenResponse) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.CreateServiceAccountTokenBody.DeepCopyInto(&dst.CreateServiceAccountTokenBody) +} + +func (CreateServiceAccountTokenResponse) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.CreateServiceAccountTokenResponse" +} + +var _ runtime.Object = NewCreateServiceAccountTokenResponse() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_body_types_gen.go new file mode 100644 index 0000000000000..6e148cf766937 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_body_types_gen.go @@ -0,0 +1,18 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type DeleteServiceAccountTokenBody struct { + Message string `json:"message"` +} + +// NewDeleteServiceAccountTokenBody creates a new DeleteServiceAccountTokenBody object. +func NewDeleteServiceAccountTokenBody() *DeleteServiceAccountTokenBody { + return &DeleteServiceAccountTokenBody{} +} + +// OpenAPIModelName returns the OpenAPI model name for DeleteServiceAccountTokenBody. +func (DeleteServiceAccountTokenBody) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.DeleteServiceAccountTokenBody" +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_object_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_object_types_gen.go new file mode 100644 index 0000000000000..0948aa7e645e7 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_deleteserviceaccounttoken_response_object_types_gen.go @@ -0,0 +1,41 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +k8s:openapi-gen=true +type DeleteServiceAccountTokenResponse struct { + metav1.TypeMeta `json:",inline"` + DeleteServiceAccountTokenBody `json:",inline"` +} + +func NewDeleteServiceAccountTokenResponse() *DeleteServiceAccountTokenResponse { + return &DeleteServiceAccountTokenResponse{} +} + +func (t *DeleteServiceAccountTokenBody) DeepCopyInto(dst *DeleteServiceAccountTokenBody) { + _ = resource.CopyObjectInto(dst, t) +} + +func (o *DeleteServiceAccountTokenResponse) DeepCopyObject() runtime.Object { + dst := NewDeleteServiceAccountTokenResponse() + o.DeepCopyInto(dst) + return dst +} + +func (o *DeleteServiceAccountTokenResponse) DeepCopyInto(dst *DeleteServiceAccountTokenResponse) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.DeleteServiceAccountTokenBody.DeepCopyInto(&dst.DeleteServiceAccountTokenBody) +} + +func (DeleteServiceAccountTokenResponse) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.DeleteServiceAccountTokenResponse" +} + +var _ runtime.Object = NewDeleteServiceAccountTokenResponse() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_getserviceaccounttoken_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_getserviceaccounttoken_response_body_types_gen.go new file mode 100644 index 0000000000000..a342136ec433c --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_getserviceaccounttoken_response_body_types_gen.go @@ -0,0 +1,40 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type GetServiceAccountTokenToken struct { + Title string `json:"title"` + Revoked bool `json:"revoked"` + Expires int64 `json:"expires"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + LastUsed int64 `json:"lastUsed"` +} + +// NewGetServiceAccountTokenToken creates a new GetServiceAccountTokenToken object. +func NewGetServiceAccountTokenToken() *GetServiceAccountTokenToken { + return &GetServiceAccountTokenToken{} +} + +// OpenAPIModelName returns the OpenAPI model name for GetServiceAccountTokenToken. +func (GetServiceAccountTokenToken) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.GetServiceAccountTokenToken" +} + +// +k8s:openapi-gen=true +type GetServiceAccountTokenBody struct { + Body GetServiceAccountTokenToken `json:"body"` +} + +// NewGetServiceAccountTokenBody creates a new GetServiceAccountTokenBody object. +func NewGetServiceAccountTokenBody() *GetServiceAccountTokenBody { + return &GetServiceAccountTokenBody{ + Body: *NewGetServiceAccountTokenToken(), + } +} + +// OpenAPIModelName returns the OpenAPI model name for GetServiceAccountTokenBody. +func (GetServiceAccountTokenBody) OpenAPIModelName() string { + return "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.GetServiceAccountTokenBody" +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_getserviceaccounttoken_response_object_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_getserviceaccounttoken ... [truncated]
← Back to Alerts View on GitHub →