Flux Operator Web UI Impersonation Bypass via Empty OIDC Claims
Description
The Flux Operator is a Kubernetes CRD controller that manages the lifecycle of CNCF Flux CD and the ControlPlane enterprise distribution. Starting in version 0.36.0 and prior to version 0.40.0, a privilege escalation vulnerability exists in the Flux Operator Web UI authentication code that allows an attacker to bypass Kubernetes RBAC impersonation and execute API requests with the operator's service account privileges. In order to be vulnerable, cluster admins must configure the Flux Operator with an OIDC provider that issues tokens lacking the expected claims (e.g., email, groups), or configure custom CEL expressions that can evaluate to empty values. After OIDC token claims are processed through CEL expressions, there is no validation that the resulting username and groups values are non-empty. When both values are empty, the Kubernetes client-go library does not add impersonation headers to API requests, causing them to be executed with the flux-operator service account's credentials instead of the authenticated user's limited permissions. This can result in privilege escalation, data exposure, and/or information disclosure. Version 0.40.0 patches the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A missing validation in Flux Operator lets empty OIDC claims bypass RBAC impersonation, executing requests with the operator's service account.
Vulnerability
Overview
The Flux Operator Web UI authentication code, present in versions 0.36.0 through 0.39.9, fails to validate that the username and groups fields derived from OIDC token claims via CEL expressions are non-empty. When both values are empty, the Kubernetes client-go library omits impersonation headers from API requests, causing those requests are executed with the flux-operator service account's full privileges instead of the authenticated user's restricted permissions. This issue affects clusters where the OIDC provider does not issue expected claims (e.g., email, groups) or where custom CEL expressions evaluate to empty values [1][4].
Exploitation
Conditions
To exploit this vulnerability, a cluster administrator must have configured the Flux Operator with an OIDC provider that issues tokens lacking email or groups claims, or with a custom CEL expression that produces empty results. An attacker with a valid token from that provider can authenticate through the Web UI; the absence of impersonation headers then allows their API calls to be processed with the operator's service account privileges [1][4]. The OIDC token signature is still validated, so a valid token is required.
Impact
An authenticated attacker who meets the exploitation conditions can escalate privileges to operator-level read permissions, gaining unauthorized access to Flux resources across all namespaces and the ability to suspend, resume, or reconcile resources. This leads to data exposure of sensitive GitOps pipeline configurations, source URLs, and deployment status, bypassing standard Kubernetes RBAC restrictions [1][4].
Mitigation & Workarounds
The vulnerability is patched in Flux Operator version 0.40.0 [1][3]. Cluster administrators unable to upgrade immediately can work around the issue by requiring the email and groups claims in the Web UI's impersonation configuration, ensuring username and groups are never both empty [4]. The fix adds validation logic that rejects impersonation configs where both username and groups are empty, as demonstrated in the commit's test additions [3].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/controlplaneio-fluxcd/flux-operatorGo | >= 0.36.0, < 0.40.0 | 0.40.0 |
Affected products
1- Range: v0.36.0, v0.37.0, v0.37.1, …
Patches
1084540424f6dMerge pull request #610 from controlplaneio-fluxcd/auth-validation
6 files changed · +277 −8
internal/web/auth/claims.go+5 −0 modified@@ -135,6 +135,11 @@ func newClaimsProcessor(conf *config.ConfigSpec) (claimsProcessorFunc, error) { imp.Groups = []string{} } + // Sanitize and validate the extracted impersonation. + if err := imp.SanitizeAndValidate(); err != nil { + return nil, fmt.Errorf("impersonation validation failed: %w", err) + } + return &user.Details{ Profile: profile, Impersonation: imp,
internal/web/auth/claims_test.go+67 −0 modified@@ -216,6 +216,73 @@ func TestClaimsProcessorFunc(t *testing.T) { wantUsername: "user@example.com", wantGroups: []string{}, }, + { + name: "impersonation validation fails when username and groups are empty", + conf: func() *config.ConfigSpec { + c := validOAuth2ConfigSpec() + c.Authentication.OAuth2.Impersonation = &config.ImpersonationSpec{ + Username: "''", + Groups: "[]", + } + return c + }(), + claims: map[string]any{ + "email": "user@example.com", + "name": "Test User", + }, + wantErr: "impersonation validation failed: at least one of 'username' or 'groups' must be set", + }, + { + name: "impersonation validation fails when group is empty string", + conf: func() *config.ConfigSpec { + c := validOAuth2ConfigSpec() + c.Authentication.OAuth2.Impersonation = &config.ImpersonationSpec{ + Username: "claims.email", + Groups: "['group1', '']", + } + return c + }(), + claims: map[string]any{ + "email": "user@example.com", + "name": "Test User", + }, + wantErr: "impersonation validation failed: group[0] is an empty string", + }, + { + name: "impersonation sanitizes whitespace from username", + conf: func() *config.ConfigSpec { + c := validOAuth2ConfigSpec() + c.Authentication.OAuth2.Impersonation = &config.ImpersonationSpec{ + Username: "' user@example.com '", + Groups: "[]", + } + return c + }(), + claims: map[string]any{ + "name": "Test User", + }, + wantProfileName: "Test User", + wantUsername: "user@example.com", + wantGroups: []string{}, + }, + { + name: "impersonation sanitizes and sorts groups", + conf: func() *config.ConfigSpec { + c := validOAuth2ConfigSpec() + c.Authentication.OAuth2.Impersonation = &config.ImpersonationSpec{ + Username: "claims.email", + Groups: "[' zebra ', ' alpha ']", + } + return c + }(), + claims: map[string]any{ + "email": "user@example.com", + "name": "Test User", + }, + wantProfileName: "Test User", + wantUsername: "user@example.com", + wantGroups: []string{"alpha", "zebra"}, + }, } { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t)
internal/web/config/authentication_types.go+6 −2 modified@@ -12,6 +12,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/fluxcd/pkg/runtime/cel" + + "github.com/controlplaneio-fluxcd/flux-operator/internal/web/user" ) const ( @@ -139,9 +141,11 @@ func (a *AnonymousAuthenticationSpec) Configured() bool { return a != nil } // Validate validates the AnonymousAuthenticationSpec configuration. func (a *AnonymousAuthenticationSpec) Validate() error { - if a.Username == "" && len(a.Groups) == 0 { - return fmt.Errorf("at least one of 'username' or 'groups' must be set for Anonymous authentication") + imp := (user.Impersonation)(*a) + if err := imp.SanitizeAndValidate(); err != nil { + return fmt.Errorf("invalid anonymous authentication impersonation: %w", err) } + *a = (AnonymousAuthenticationSpec)(imp) return nil }
internal/web/config/authentication_types_test.go+61 −6 modified@@ -173,9 +173,11 @@ func TestAnonymousAuthenticationSpec_Configured(t *testing.T) { func TestAnonymousAuthenticationSpec_Validate(t *testing.T) { for _, tt := range []struct { - name string - spec *AnonymousAuthenticationSpec - wantErr string + name string + spec *AnonymousAuthenticationSpec + wantErr string + wantUsername string + wantGroups []string }{ { name: "missing both username and groups", @@ -187,29 +189,82 @@ func TestAnonymousAuthenticationSpec_Validate(t *testing.T) { spec: &AnonymousAuthenticationSpec{ Username: "test-user", }, - wantErr: "", + wantUsername: "test-user", + wantGroups: []string{}, }, { name: "has groups only", spec: &AnonymousAuthenticationSpec{ Groups: []string{"group1", "group2"}, }, - wantErr: "", + wantUsername: "", + wantGroups: []string{"group1", "group2"}, }, { name: "has both username and groups", spec: &AnonymousAuthenticationSpec{ Username: "test-user", Groups: []string{"group1"}, }, - wantErr: "", + wantUsername: "test-user", + wantGroups: []string{"group1"}, + }, + { + name: "trims whitespace from username", + spec: &AnonymousAuthenticationSpec{ + Username: " test-user ", + }, + wantUsername: "test-user", + wantGroups: []string{}, + }, + { + name: "trims whitespace from groups", + spec: &AnonymousAuthenticationSpec{ + Groups: []string{" group1 ", " group2 "}, + }, + wantUsername: "", + wantGroups: []string{"group1", "group2"}, + }, + { + name: "sorts groups alphabetically", + spec: &AnonymousAuthenticationSpec{ + Username: "test-user", + Groups: []string{"zebra", "alpha", "middle"}, + }, + wantUsername: "test-user", + wantGroups: []string{"alpha", "middle", "zebra"}, + }, + { + name: "whitespace-only username with no groups fails", + spec: &AnonymousAuthenticationSpec{ + Username: " ", + }, + wantErr: "at least one of 'username' or 'groups' must be set", + }, + { + name: "empty string in groups fails", + spec: &AnonymousAuthenticationSpec{ + Username: "test-user", + Groups: []string{"group1", ""}, + }, + wantErr: "group[0] is an empty string", + }, + { + name: "whitespace-only group fails", + spec: &AnonymousAuthenticationSpec{ + Username: "test-user", + Groups: []string{"group1", " "}, + }, + wantErr: "group[0] is an empty string", }, } { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) err := tt.spec.Validate() if tt.wantErr == "" { g.Expect(err).NotTo(HaveOccurred()) + g.Expect(tt.spec.Username).To(Equal(tt.wantUsername)) + g.Expect(tt.spec.Groups).To(Equal(tt.wantGroups)) } else { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
internal/web/user/user.go+21 −0 modified@@ -28,6 +28,27 @@ type Impersonation struct { Groups []string `json:"groups"` } +// SanitizeAndValidate sanitizes and validates the user impersonation details. +func (imp *Impersonation) SanitizeAndValidate() error { + imp.Username = strings.TrimSpace(imp.Username) + for i, g := range imp.Groups { + imp.Groups[i] = strings.TrimSpace(g) + } + if imp.Groups == nil { + imp.Groups = []string{} + } + slices.Sort(imp.Groups) + if imp.Username == "" && len(imp.Groups) == 0 { + return fmt.Errorf("at least one of 'username' or 'groups' must be set for user impersonation") + } + for i, g := range imp.Groups { + if g == "" { + return fmt.Errorf("group[%d] is an empty string", i) + } + } + return nil +} + // session holds the user session information during the life of a request. type session struct { Details
internal/web/user/user_test.go+117 −0 modified@@ -11,6 +11,123 @@ import ( . "github.com/onsi/gomega" ) +func TestImpersonation_SanitizeAndValidate(t *testing.T) { + for _, tt := range []struct { + name string + imp Impersonation + wantErr string + wantUsername string + wantGroups []string + }{ + { + name: "valid username only", + imp: Impersonation{ + Username: "user@example.com", + }, + wantUsername: "user@example.com", + wantGroups: []string{}, + }, + { + name: "valid groups only", + imp: Impersonation{ + Groups: []string{"group1", "group2"}, + }, + wantUsername: "", + wantGroups: []string{"group1", "group2"}, + }, + { + name: "valid username and groups", + imp: Impersonation{ + Username: "user@example.com", + Groups: []string{"admin", "developer"}, + }, + wantUsername: "user@example.com", + wantGroups: []string{"admin", "developer"}, + }, + { + name: "trims whitespace from username", + imp: Impersonation{ + Username: " user@example.com ", + Groups: []string{"group1"}, + }, + wantUsername: "user@example.com", + wantGroups: []string{"group1"}, + }, + { + name: "trims whitespace from groups", + imp: Impersonation{ + Username: "user@example.com", + Groups: []string{" group1 ", " group2 "}, + }, + wantUsername: "user@example.com", + wantGroups: []string{"group1", "group2"}, + }, + { + name: "sorts groups alphabetically", + imp: Impersonation{ + Username: "user@example.com", + Groups: []string{"zebra", "alpha", "middle"}, + }, + wantUsername: "user@example.com", + wantGroups: []string{"alpha", "middle", "zebra"}, + }, + { + name: "nil groups becomes empty slice", + imp: Impersonation{ + Username: "user@example.com", + Groups: nil, + }, + wantUsername: "user@example.com", + wantGroups: []string{}, + }, + { + name: "missing both username and groups fails", + imp: Impersonation{ + Username: "", + Groups: []string{}, + }, + wantErr: "at least one of 'username' or 'groups' must be set for user impersonation", + }, + { + name: "whitespace-only username with no groups fails", + imp: Impersonation{ + Username: " ", + Groups: []string{}, + }, + wantErr: "at least one of 'username' or 'groups' must be set for user impersonation", + }, + { + name: "empty string in groups fails", + imp: Impersonation{ + Username: "user@example.com", + Groups: []string{"group1", "", "group2"}, + }, + wantErr: "group[0] is an empty string", + }, + { + name: "whitespace-only group becomes empty string and fails", + imp: Impersonation{ + Username: "user@example.com", + Groups: []string{"group1", " "}, + }, + wantErr: "group[0] is an empty string", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + err := tt.imp.SanitizeAndValidate() + if tt.wantErr == "" { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(tt.imp.Username).To(Equal(tt.wantUsername)) + g.Expect(tt.imp.Groups).To(Equal(tt.wantGroups)) + } else { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + } + }) + } +} + func TestSessionKey(t *testing.T) { t.Run("nil session returns privileged-user", func(t *testing.T) { g := NewWithT(t)
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-4xh5-jcj2-ch8qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23990ghsaADVISORY
- github.com/controlplaneio-fluxcd/flux-operator/commit/084540424f6de8ba5d88fb1fd1e8472ba29afd7eghsax_refsource_MISCWEB
- github.com/controlplaneio-fluxcd/flux-operator/pull/610ghsax_refsource_MISCWEB
- github.com/controlplaneio-fluxcd/flux-operator/releases/tag/v0.40.0ghsax_refsource_MISCWEB
- github.com/controlplaneio-fluxcd/flux-operator/security/advisories/GHSA-4xh5-jcj2-ch8qghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.