Omni: Reader-level users can retrieve imported cluster CA keys via ResourceService
Description
Summary
Omni supports importing standalone Talos clusters.
During this process, an ImportedClusterSecrets resource is created, which contains the full CA secrets bundle for the cluster being imported.
If these secrets are not rotated by the importing actor, an authenticated Omni user with Reader access can read this resource and gain full access to the Talos, Kubernetes and etcd APIs of the cluster.
Severity
- Attack Vector: Adjacent: the attacker needs to be in the same network to be able to access Talos/Kubernetes APIs with the compromised keys.
- Attack Complexity: High: the attacker needs a deep understanding of Omni's internals. The resource is only created for imported clusters, and is normally not represented to users via any high-level API.
- Privileges Required: Low: the role
Readeris sufficient for the attacker to be able to read an imported cluster's secrets. - User Interaction: Required: another user must have imported a cluster to Omni for this vulnerability to exist.
- Scope: Changed: the leaked CA private keys let an attacker directly get full control on Kubernetes or Talos, beyond the limitations enforced by Omni.
- Confidentiality Impact: High: full cluster CA private keys (Kubernetes, Talos, etcd, service account) are exposed.
- Integrity Impact: High: with the CA keys the attacker has full control on Kubernetes and Talos of the compromised (imported) cluster, and modify the workloads on it.
- Availability Impact: High: with the CA keys the attacker has full control on Kubernetes and Talos of the compromised (imported) cluster, and modify the workloads on it.
Impact
- Any Reader-level account can exfiltrate the complete CA private key hierarchy (Kubernetes CA, etcd CA, service account key) of the imported clusters whose secrets are not yet rotated ("tainted" imported clusters).
- With the Kubernetes CA private key, an attacker can sign certificates for any Kubernetes user or group, including
system:masters, achieving cluster-admin access to the imported cluster entirely outside Omni's control plane. - Impact scope extends beyond Omni to every Kubernetes workload, credential, and secret stored in the affected imported cluster.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Affected products
2Patches
4846051007433feat: destroy imported cluster secrets after bundle is consumed
4 files changed · +151 −0
internal/backend/runtime/omni/controllers/omni/secrets/cleanup.go+97 −0 added@@ -0,0 +1,97 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. + +package secrets + +import ( + "context" + "fmt" + "time" + + "github.com/cosi-project/runtime/pkg/controller" + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "go.uber.org/zap" + + "github.com/siderolabs/omni/client/pkg/omni/resources" + "github.com/siderolabs/omni/client/pkg/omni/resources/omni" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/helpers" +) + +// ImportedClusterSecretsCleanupControllerName is the name of the controller. +const ImportedClusterSecretsCleanupControllerName = "ImportedClusterSecretsCleanupController" + +// ImportedClusterSecretsCleanupController destroys ImportedClusterSecrets once the SecretsController +// has copied the secrets bundle into the matching ClusterSecrets and marked it as imported, +// so the source secrets do not linger in the state after they have been consumed. +type ImportedClusterSecretsCleanupController struct{} + +// Name implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Name() string { + return ImportedClusterSecretsCleanupControllerName +} + +// Settings implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Settings() controller.QSettings { + return controller.QSettings{ + Inputs: []controller.Input{ + { + Namespace: resources.DefaultNamespace, + Type: omni.ClusterSecretsType, + Kind: controller.InputQPrimary, + }, + }, + Outputs: []controller.Output{ + { + Type: omni.ImportedClusterSecretsType, + Kind: controller.OutputShared, + }, + }, + } +} + +// Reconcile implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Reconcile(ctx context.Context, logger *zap.Logger, r controller.QRuntime, ptr resource.Pointer) error { + cs, err := safe.ReaderGetByID[*omni.ClusterSecrets](ctx, r, ptr.ID()) + if err != nil { + if state.IsNotFoundError(err) { + return nil + } + + return fmt.Errorf("failed to get ClusterSecrets %q: %w", ptr.ID(), err) + } + + if !cs.TypedSpec().Value.GetImported() { + return nil + } + + ics, err := safe.ReaderGetByID[*omni.ImportedClusterSecrets](ctx, r, cs.Metadata().ID()) + if err != nil { + if state.IsNotFoundError(err) { + return nil + } + + return fmt.Errorf("failed to get ImportedClusterSecrets %q: %w", cs.Metadata().ID(), err) + } + + logger.Info("destroying consumed imported cluster secrets", zap.String("id", ics.Metadata().ID())) + + destroyed, err := helpers.TeardownAndDestroy(ctx, r, ics.Metadata(), controller.WithOwner("")) + if err != nil { + return fmt.Errorf("failed to teardown and destroy ImportedClusterSecrets %q: %w", ics.Metadata().ID(), err) + } + + if !destroyed { + return controller.NewRequeueError(fmt.Errorf("waiting for ImportedClusterSecrets %q to be destroyed", ics.Metadata().ID()), 10*time.Second) + } + + return nil +} + +// MapInput implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) MapInput(context.Context, *zap.Logger, controller.QRuntime, controller.ReducedResourceMetadata) ([]resource.Pointer, error) { + return nil, nil +}
internal/backend/runtime/omni/controllers/omni/secrets/cleanup_test.go+51 −0 added@@ -0,0 +1,51 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. + +package secrets_test + +import ( + "context" + "testing" + + "github.com/cosi-project/runtime/pkg/resource/rtestutils" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/omni/client/pkg/omni/resources/omni" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/omni/secrets" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/testutils" +) + +func TestImportedClusterSecretsCleanup(t *testing.T) { + t.Parallel() + + t.Run("destroys ICS once ClusterSecrets is marked imported", func(t *testing.T) { + t.Parallel() + + testutils.WithRuntime( + t.Context(), + t, + testutils.TestOptions{}, + func(_ context.Context, testContext testutils.TestContext) { + require.NoError(t, testContext.Runtime.RegisterQController(&secrets.ImportedClusterSecretsCleanupController{})) + }, + func(ctx context.Context, testContext testutils.TestContext) { + st := testContext.State + + const clusterID = "imported-cluster" + + ics := omni.NewImportedClusterSecrets(clusterID) + ics.TypedSpec().Value.Data = "secret-bundle" + require.NoError(t, st.Create(ctx, ics)) + + cs := omni.NewClusterSecrets(clusterID) + cs.TypedSpec().Value.Data = []byte("derived-bundle") + cs.TypedSpec().Value.Imported = true + require.NoError(t, st.Create(ctx, cs)) + + rtestutils.AssertNoResource[*omni.ImportedClusterSecrets](ctx, t, st, clusterID) + }, + ) + }) +}
internal/backend/runtime/omni/omni.go+1 −0 modified@@ -230,6 +230,7 @@ func NewRuntime(cfg *config.Params, talosClientFactory *talos.ClientFactory, dns redactedmachineconfig.NewController(redactedmachineconfig.ControllerOptions{}), omnictrl.NewSchematicConfigurationController(imageFactoryClient), secrets.NewSecretsController(storeFactory), + &secrets.ImportedClusterSecretsCleanupController{}, secrets.NewTalosConfigController(constants.CertificateValidityTime), omnictrl.NewTalosExtensionsController(imageFactoryClient), omnictrl.NewMachineStatusSnapshotController(siderolinkEventsCh, powerStageEventsCh),
internal/integration/import_test.go+2 −0 modified@@ -118,6 +118,8 @@ func testImport(t *testing.T, options *TestOptions, clusterID string, clusterNod _, err = talosClient.Version(client.WithNode(ctx, clusterNodes[0])) require.NoError(t, err) + + rtestutils.AssertNoResource[*omni.ImportedClusterSecrets](ctx, t, omniState, clusterID) } func testUnlockCluster(t *testing.T, options *TestOptions, clusterID string) {
bb01bd81b463feat: destroy imported cluster secrets after bundle is consumed
4 files changed · +151 −0
internal/backend/runtime/omni/controllers/omni/secrets/cleanup.go+97 −0 added@@ -0,0 +1,97 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. + +package secrets + +import ( + "context" + "fmt" + "time" + + "github.com/cosi-project/runtime/pkg/controller" + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "go.uber.org/zap" + + "github.com/siderolabs/omni/client/pkg/omni/resources" + "github.com/siderolabs/omni/client/pkg/omni/resources/omni" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/helpers" +) + +// ImportedClusterSecretsCleanupControllerName is the name of the controller. +const ImportedClusterSecretsCleanupControllerName = "ImportedClusterSecretsCleanupController" + +// ImportedClusterSecretsCleanupController destroys ImportedClusterSecrets once the SecretsController +// has copied the secrets bundle into the matching ClusterSecrets and marked it as imported, +// so the source secrets do not linger in the state after they have been consumed. +type ImportedClusterSecretsCleanupController struct{} + +// Name implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Name() string { + return ImportedClusterSecretsCleanupControllerName +} + +// Settings implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Settings() controller.QSettings { + return controller.QSettings{ + Inputs: []controller.Input{ + { + Namespace: resources.DefaultNamespace, + Type: omni.ClusterSecretsType, + Kind: controller.InputQPrimary, + }, + }, + Outputs: []controller.Output{ + { + Type: omni.ImportedClusterSecretsType, + Kind: controller.OutputShared, + }, + }, + } +} + +// Reconcile implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) Reconcile(ctx context.Context, logger *zap.Logger, r controller.QRuntime, ptr resource.Pointer) error { + cs, err := safe.ReaderGetByID[*omni.ClusterSecrets](ctx, r, ptr.ID()) + if err != nil { + if state.IsNotFoundError(err) { + return nil + } + + return fmt.Errorf("failed to get ClusterSecrets %q: %w", ptr.ID(), err) + } + + if !cs.TypedSpec().Value.GetImported() { + return nil + } + + ics, err := safe.ReaderGetByID[*omni.ImportedClusterSecrets](ctx, r, cs.Metadata().ID()) + if err != nil { + if state.IsNotFoundError(err) { + return nil + } + + return fmt.Errorf("failed to get ImportedClusterSecrets %q: %w", cs.Metadata().ID(), err) + } + + logger.Info("destroying consumed imported cluster secrets", zap.String("id", ics.Metadata().ID())) + + destroyed, err := helpers.TeardownAndDestroy(ctx, r, ics.Metadata(), controller.WithOwner("")) + if err != nil { + return fmt.Errorf("failed to teardown and destroy ImportedClusterSecrets %q: %w", ics.Metadata().ID(), err) + } + + if !destroyed { + return controller.NewRequeueError(fmt.Errorf("waiting for ImportedClusterSecrets %q to be destroyed", ics.Metadata().ID()), 10*time.Second) + } + + return nil +} + +// MapInput implements controller.QController interface. +func (ctrl *ImportedClusterSecretsCleanupController) MapInput(context.Context, *zap.Logger, controller.QRuntime, controller.ReducedResourceMetadata) ([]resource.Pointer, error) { + return nil, nil +}
internal/backend/runtime/omni/controllers/omni/secrets/cleanup_test.go+51 −0 added@@ -0,0 +1,51 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. + +package secrets_test + +import ( + "context" + "testing" + + "github.com/cosi-project/runtime/pkg/resource/rtestutils" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/omni/client/pkg/omni/resources/omni" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/omni/secrets" + "github.com/siderolabs/omni/internal/backend/runtime/omni/controllers/testutils" +) + +func TestImportedClusterSecretsCleanup(t *testing.T) { + t.Parallel() + + t.Run("destroys ICS once ClusterSecrets is marked imported", func(t *testing.T) { + t.Parallel() + + testutils.WithRuntime( + t.Context(), + t, + testutils.TestOptions{}, + func(_ context.Context, testContext testutils.TestContext) { + require.NoError(t, testContext.Runtime.RegisterQController(&secrets.ImportedClusterSecretsCleanupController{})) + }, + func(ctx context.Context, testContext testutils.TestContext) { + st := testContext.State + + const clusterID = "imported-cluster" + + ics := omni.NewImportedClusterSecrets(clusterID) + ics.TypedSpec().Value.Data = "secret-bundle" + require.NoError(t, st.Create(ctx, ics)) + + cs := omni.NewClusterSecrets(clusterID) + cs.TypedSpec().Value.Data = []byte("derived-bundle") + cs.TypedSpec().Value.Imported = true + require.NoError(t, st.Create(ctx, cs)) + + rtestutils.AssertNoResource[*omni.ImportedClusterSecrets](ctx, t, st, clusterID) + }, + ) + }) +}
internal/backend/runtime/omni/omni.go+1 −0 modified@@ -244,6 +244,7 @@ func NewRuntime(cfg *config.Params, talosClientFactory *talos.ClientFactory, dns redactedmachineconfig.NewController(redactedmachineconfig.ControllerOptions{}), omnictrl.NewSchematicConfigurationController(imageFactoryClient), secrets.NewSecretsController(storeFactory), + &secrets.ImportedClusterSecretsCleanupController{}, secrets.NewTalosConfigController(constants.CertificateValidityTime), omnictrl.NewTalosExtensionsController(imageFactoryClient), omnictrl.NewMachineStatusSnapshotController(siderolinkEventsCh, powerStageEventsCh),
internal/integration/import_test.go+2 −0 modified@@ -118,6 +118,8 @@ func testImport(t *testing.T, options *TestOptions, clusterID string, clusterNod _, err = talosClient.Version(client.WithNode(ctx, clusterNodes[0])) require.NoError(t, err) + + rtestutils.AssertNoResource[*omni.ImportedClusterSecrets](ctx, t, omniState, clusterID) } func testUnlockCluster(t *testing.T, options *TestOptions, clusterID string) {
b8ca100c4539fix: change ImportedClusterSecrets access level to operator
2 files changed · +16 −17
internal/backend/runtime/omni/state_access.go+2 −1 modified@@ -406,7 +406,6 @@ func filterAccess(ctx context.Context, access state.Access) error { omni.EtcdManualBackupType, omni.ImagePullRequestType, omni.ImagePullStatusType, - omni.ImportedClusterSecretsType, omni.InfraMachineConfigType, omni.KubernetesStatusType, omni.KubernetesUpgradeManifestStatusType, @@ -503,6 +502,8 @@ func filterAccess(ctx context.Context, access state.Access) error { } case authres.AuthConfigType: // allow access even without auth + case omni.ImportedClusterSecretsType: + _, err = auth.CheckGRPC(ctx, auth.WithRole(role.Operator)) default: err = status.Error(codes.PermissionDenied, "no access is permitted") }
internal/integration/auth_test.go+14 −16 modified@@ -792,6 +792,7 @@ type resourceAuthzTestCase struct { resource resource.Resource allowedVerbSet map[state.Verb]struct{} isAdminOnly bool + isOperatorOnly bool isSignatureSufficient bool isPublic bool isDestroyNotAllowed bool @@ -947,6 +948,7 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client { resource: importedClusterSecret, allowedVerbSet: allVerbsSet, + isOperatorOnly: true, }, { resource: grpcTunnelConfig, @@ -1464,28 +1466,24 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client isOperator := testRole.Check(role.Operator) == nil isAdmin := testRole.Check(role.Admin) == nil _, verbAllowed := tc.allowedVerbSet[testVerb] - sufficientRole := true - if tc.isAdminOnly { - if !isAdmin { - sufficientRole = false - } - } else { + var sufficientRole bool + + switch { + case tc.isSignatureSufficient || tc.isPublic: + sufficientRole = true + case tc.isAdminOnly: + sufficientRole = isAdmin + case tc.isOperatorOnly: + sufficientRole = isOperator + default: if testVerb.Readonly() { - if !isReader { - sufficientRole = false - } + sufficientRole = isReader } else { - if !isOperator { - sufficientRole = false - } + sufficientRole = isOperator } } - if tc.isSignatureSufficient || tc.isPublic { - sufficientRole = true - } - switch { case !verbAllowed && !sufficientRole: // we check for either of these errors because their order is not guaranteed
a224cb020f3afix: change ImportedClusterSecrets access level to operator
2 files changed · +16 −17
internal/backend/runtime/omni/state_access.go+2 −1 modified@@ -432,7 +432,6 @@ func filterAccess(ctx context.Context, access state.Access) error { omni.EtcdManualBackupType, omni.ImagePullRequestType, omni.ImagePullStatusType, - omni.ImportedClusterSecretsType, omni.InfraMachineConfigType, omni.KubernetesStatusType, omni.KubernetesUpgradeManifestStatusType, @@ -528,6 +527,8 @@ func filterAccess(ctx context.Context, access state.Access) error { if err == nil && !access.Verb.Readonly() && (access.ResourceType == authres.IdentityType || access.ResourceType == authres.UserType) { err = status.Errorf(codes.PermissionDenied, "only read access is permitted on resource %v, mutations should be done via ManagementService API calls", access.ResourceType) } + case omni.ImportedClusterSecretsType: + _, err = auth.CheckGRPC(ctx, auth.WithRole(role.Operator)) default: err = status.Error(codes.PermissionDenied, "no access is permitted") }
internal/integration/auth_test.go+14 −16 modified@@ -792,6 +792,7 @@ type resourceAuthzTestCase struct { resource resource.Resource allowedVerbSet map[state.Verb]struct{} isAdminOnly bool + isOperatorOnly bool isSignatureSufficient bool isPublic bool isDestroyNotAllowed bool @@ -948,6 +949,7 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client { resource: importedClusterSecret, allowedVerbSet: allVerbsSet, + isOperatorOnly: true, }, { resource: grpcTunnelConfig, @@ -1478,28 +1480,24 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client isOperator := testRole.Check(role.Operator) == nil isAdmin := testRole.Check(role.Admin) == nil _, verbAllowed := tc.allowedVerbSet[testVerb] - sufficientRole := true - if tc.isAdminOnly { - if !isAdmin { - sufficientRole = false - } - } else { + var sufficientRole bool + + switch { + case tc.isSignatureSufficient || tc.isPublic: + sufficientRole = true + case tc.isAdminOnly: + sufficientRole = isAdmin + case tc.isOperatorOnly: + sufficientRole = isOperator + default: if testVerb.Readonly() { - if !isReader { - sufficientRole = false - } + sufficientRole = isReader } else { - if !isOperator { - sufficientRole = false - } + sufficientRole = isOperator } } - if tc.isSignatureSufficient || tc.isPublic { - sufficientRole = true - } - switch { case !verbAllowed && !sufficientRole: // we check for either of these errors because their order is not guaranteed
Vulnerability mechanics
Root cause
"The ImportedClusterSecrets resource, containing sensitive cluster CA secrets, was accessible to authenticated users with only Reader privileges."
Attack vector
An attacker with Reader access to Omni must first ensure that a cluster has been imported into Omni. The attacker then reads the ImportedClusterSecrets resource, which contains the full CA secrets bundle for the imported cluster. This resource is created during the import process and is normally not exposed via high-level APIs. The attacker needs to be on the same network to access the Talos/Kubernetes APIs with the compromised keys [CWE-200].
Affected code
The vulnerability lies within the access control for the `ImportedClusterSecretsType` resource, specifically in `internal/backend/runtime/omni/state_access.go`. The fix involves modifying this access control to require the Operator role and implementing a cleanup controller in `internal/backend/runtime/omni/controllers/omni/secrets/cleanup.go`.
What the fix does
The patch restricts access to the ImportedClusterSecrets resource, requiring at least an Operator role instead of the previous Reader role [patch_id=4935403, patch_id=4935405]. Additionally, a new controller was introduced to automatically destroy the ImportedClusterSecrets resource once its contents have been successfully consumed and marked as imported in the ClusterSecrets resource [patch_id=4935404, patch_id=4935406]. This prevents the sensitive secrets from lingering and being accessible to unauthorized users.
Preconditions
- inputA cluster must have been imported into Omni.
- authThe attacker must have authenticated as a user with at least Reader privileges.
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.