Radius Controller May Delete a Container Resource via an Injected Deployment Annotation (Multi-Tenant Installs)
Description
Radius controller in multi-tenant installs can delete another tenant's container via an injected radapp.io/status annotation.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Radius controller in multi-tenant installs can delete another tenant's container via an injected radapp.io/status annotation.
Vulnerability
The Radius Kubernetes controller in versions v0.57.1 and earlier [1] deserializes user-controllable JSON from the radapp.io/status annotation on Deployments without validating tenant ownership. The vulnerable code is in pkg/controller/reconciler/annotations.go:110-119 [1]. This allows an attacker with write access to a Deployment's annotations to inject a resource ID belonging to a different tenant.
Exploitation
An attacker must have the ability to modify the radapp.io/status annotation on a Deployment in a multi-tenant Radius install where one controller reconciles Deployments across resource groups owned by different teams [1]. The attacker sets the annotation to reference a container resource ID from another tenant. When the controller processes the Deployment, it uses its own high-privilege credentials to issue a DELETE request to the Radius API for that container [1][2].
Impact
Successful exploitation results in deletion of a container resource belonging to another tenant in a multi-tenant install [1]. There is no data disclosure, privilege escalation, or persistence. Deleted resources are recoverable through standard Radius deployment workflows [1]. In single-tenant installs, the impact is limited to self-DoS (deleting one's own container) [1].
Mitigation
The fix is included in Radius v0.58.0 [3]. Users should upgrade to v0.58.0 or later. For multi-tenant installs, strict RBAC policies limiting annotation modification can reduce risk [1]. No workaround is available for unpatched versions.
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2- Range: < 0.58.0
Patches
1c4174fc205b8fix: tenant validation (#11967)
22 files changed · +477 −39
deploy/Chart/templates/controller/configmaps.yaml+1 −2 modified@@ -53,7 +53,6 @@ data: tracerProvider: enabled: true serviceName: "controller" - zipkin: + zipkin: url: {{ .Values.global.zipkin.url }} {{- end }} - \ No newline at end of file
deploy/Chart/templates/controller/deployment.yaml+3 −3 modified@@ -59,8 +59,8 @@ spec: containers: - name: controller image: "{{ include "radius.image" (dict "image" .Values.controller.image "tag" (.Values.controller.tag | default .Values.global.imageTag | default $appversion) "global" .Values.global) }}" - imagePullPolicy: 'Always' - args: + imagePullPolicy: {{ .Values.controller.imagePullPolicy | default "Always" | quote }} + args: - '--config-file' - '/etc/config/controller-config.yaml' env: @@ -132,4 +132,4 @@ spec: {{- if .Values.global.appendRootCA.cert }} - name: ssl-certs emptyDir: {} - {{- end }} \ No newline at end of file + {{- end }}
deploy/Chart/templates/controller/rbac.yaml+1 −1 modified@@ -93,4 +93,4 @@ roleRef: subjects: - kind: ServiceAccount name: controller - namespace: {{ .Release.Namespace }} \ No newline at end of file + namespace: {{ .Release.Namespace }}
deploy/Chart/templates/controller/service.yaml+1 −1 modified@@ -13,4 +13,4 @@ spec: protocol: TCP targetPort: 9443 selector: - app.kubernetes.io/name: controller + app.kubernetes.io/name: controller
deploy/Chart/templates/controller/validating-webhook-configuration.yaml+1 −1 modified@@ -44,4 +44,4 @@ webhooks: - UPDATE resources: - recipes - sideEffects: None \ No newline at end of file + sideEffects: None
deploy/Chart/templates/rp/annotation-protection-admission.yaml+41 −0 added@@ -0,0 +1,41 @@ +{{- if .Values.rp.security.annotationProtection.enabled }} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: radius-rp-annotation-protection + labels: + app.kubernetes.io/name: applications-rp + app.kubernetes.io/part-of: radius +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["UPDATE"] + resources: ["deployments"] + validations: + - expression: >- + request.userInfo.username == "system:serviceaccount:{{ .Release.Namespace }}:{{ .Values.rp.security.annotationProtection.allowedServiceAccountName }}" || + ( + (!has(oldObject.metadata.annotations) || !oldObject.metadata.annotations.exists(k, k == "radapp.io/status")) && + (!has(object.metadata.annotations) || !object.metadata.annotations.exists(k, k == "radapp.io/status")) + ) || + ( + has(oldObject.metadata.annotations) && oldObject.metadata.annotations.exists(k, k == "radapp.io/status") && + has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == "radapp.io/status") && + object.metadata.annotations["radapp.io/status"] == oldObject.metadata.annotations["radapp.io/status"] + ) + message: "modifying annotation radapp.io/status is restricted to the Radius controller service account system:serviceaccount:{{ .Release.Namespace }}:{{ .Values.rp.security.annotationProtection.allowedServiceAccountName }}" +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: radius-rp-annotation-protection-binding + labels: + app.kubernetes.io/name: applications-rp + app.kubernetes.io/part-of: radius +spec: + policyName: radius-rp-annotation-protection + validationActions: [Deny] +{{- end }}
deploy/Chart/templates/rp/configmaps.yaml+1 −1 modified@@ -50,7 +50,7 @@ data: tracerProvider: enabled: true serviceName: "applications-rp" - zipkin: + zipkin: url: {{ .Values.global.zipkin.url }} {{- end }} bicep:
deploy/Chart/templates/rp/deployment.yaml+13 −13 modified@@ -64,11 +64,11 @@ spec: echo "Terraform init container starting..." echo "Running on architecture: $(uname -m)" echo "Alpine version: $(cat /etc/alpine-release)" - + # Create terraform directory mkdir -p "{{ .Values.rp.terraform.path }}" echo "Created directory: {{ .Values.rp.terraform.path }}" - + # Detect architecture for terraform download ARCH=$(uname -m) case $ARCH in @@ -77,16 +77,16 @@ spec: *) echo "ERROR: Unsupported architecture: $ARCH"; exit 1 ;; esac echo "Terraform architecture: $TERRAFORM_ARCH" - + # Install wget and unzip if not available (Alpine doesn't include them by default) if ! which wget >/dev/null 2>&1 || ! which unzip >/dev/null 2>&1; then echo "Installing wget and unzip..." apk add --no-cache wget unzip || { echo "ERROR: Failed to install wget/unzip"; exit 3; } fi - + # Determine download URL TERRAFORM_URL="{{ .Values.global.terraform.downloadUrl }}" - + # If no custom URL provided, fetch latest version from HashiCorp API if [[ -z "$TERRAFORM_URL" ]]; then echo "Fetching latest Terraform version from HashiCorp API..." @@ -98,36 +98,36 @@ spec: echo "Latest Terraform version: $LATEST_VERSION" TERRAFORM_URL="https://releases.hashicorp.com/terraform/${LATEST_VERSION}/terraform_${LATEST_VERSION}_linux_${TERRAFORM_ARCH}.zip" fi - + echo "Download URL: $TERRAFORM_URL" - + # Basic connectivity and environment info echo "Environment debug info:" echo "- Date: $(date)" echo "- PWD: $(pwd)" echo "- Available commands: $(which wget || echo 'no wget') $(which unzip || echo 'no unzip')" - + # Install wget and unzip if not available (Alpine doesn't include them by default) if ! which wget >/dev/null 2>&1 || ! which unzip >/dev/null 2>&1; then echo "Installing wget and unzip..." apk add --no-cache wget unzip || { echo "ERROR: Failed to install wget/unzip"; exit 3; } fi - + # Download and extract terraform using wget cd /tmp echo "Downloading terraform using wget..." wget "${TERRAFORM_URL}" -O terraform.zip || { echo "ERROR: Failed to download terraform"; exit 4; } - + echo "Extracting terraform using unzip..." unzip terraform.zip || { echo "ERROR: Failed to extract terraform"; exit 5; } - + echo "Installing terraform binary..." cp terraform "{{ .Values.rp.terraform.path }}/terraform" || { echo "ERROR: Failed to copy terraform"; exit 6; } chmod +x "{{ .Values.rp.terraform.path }}/terraform" || { echo "ERROR: Failed to make terraform executable"; exit 7; } - + # Create marker file to indicate pre-mounted binary is available echo "pre-mounted" > "{{ .Values.rp.terraform.path }}/.terraform-source" - + echo "Terraform binary successfully pre-downloaded and installed" volumeMounts: - name: terraform
deploy/Chart/values.yaml+8 −0 modified@@ -141,6 +141,14 @@ rp: deleteRetryDelaySeconds: 60 terraform: path: "/terraform" + security: + annotationProtection: + # Enable ValidatingAdmissionPolicy to protect radapp.io/status from user tampering. + # Keep disabled by default for compatibility with clusters that do not support + # admissionregistration.k8s.io/v1 ValidatingAdmissionPolicy. + enabled: false + # Service account allowed to modify protected annotations. + allowedServiceAccountName: "applications-rp" dashboard: enabled: true
pkg/cli/bicep/resources.go+3 −3 modified@@ -85,9 +85,9 @@ func ExtractResourceTypes(template map[string]any) []ResourceTypeEntry { } entry := ResourceTypeEntry{FullType: resourceType} - if idx := strings.Index(resourceType, "@"); idx >= 0 { - entry.Type = resourceType[:idx] - entry.APIVersion = resourceType[idx+1:] + if before, after, ok0 := strings.Cut(resourceType, "@"); ok0 { + entry.Type = before + entry.APIVersion = after } else { entry.Type = resourceType }
pkg/cli/cmd/env/create/preview/create_test.go+1 −0 modified@@ -200,6 +200,7 @@ func Test_Run(t *testing.T) { err = runner.Run(context.Background()) require.NoError(t, err) + require.Equal(t, expectedOutput, outputSink.Writes) })
pkg/cli/cmd/env/update/preview/update.go+1 −1 modified@@ -228,7 +228,7 @@ func normalizeRecipePacks(recipepacks []string) []string { seen := map[string]struct{}{} result := []string{} for _, value := range recipepacks { - for _, p := range strings.Split(value, ",") { + for p := range strings.SplitSeq(value, ",") { trimmed := strings.TrimSpace(p) if trimmed == "" { continue
pkg/cli/cmd/radinit/preview/init_test.go+1 −1 modified@@ -1150,7 +1150,7 @@ type cloudProviderPromptMatcher struct { } // Matches implements gomock.Matcher -func (*cloudProviderPromptMatcher) Matches(x interface{}) bool { +func (*cloudProviderPromptMatcher) Matches(x any) bool { return x == confirmCloudProviderPrompt || x == confirmCloudProviderAdditionalPrompt }
pkg/cli/cmd/resource/delete/delete_test.go+1 −1 modified@@ -512,7 +512,7 @@ func Test_Run(t *testing.T) { appManagementClient.EXPECT(). GetResource(gomock.Any(), "Applications.Core/containers", "test-container"). Return(generated.GenericResource{ - Properties: map[string]interface{}{ + Properties: map[string]any{ "environment": "/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/environments/my-test-env", "application": "/planes/radius/local/resourceGroups/test-group/providers/Applications.Core/applications/my-test-app", },
pkg/controller/reconciler/annotations.go+41 −0 modified@@ -24,6 +24,7 @@ import ( "strings" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + "github.com/radius-project/radius/pkg/ucp/resources" appsv1 "k8s.io/api/apps/v1" ) @@ -115,6 +116,11 @@ func readAnnotations(deployment *appsv1.Deployment) (deploymentAnnotations, erro return result, fmt.Errorf("failed to unmarshal status annotation: %w", err) } + err = validateDeploymentStatus(&s) + if err != nil { + return result, fmt.Errorf("invalid status annotation: %w", err) + } + result.Status = &s } @@ -140,6 +146,41 @@ func readAnnotations(deployment *appsv1.Deployment) (deploymentAnnotations, erro return result, nil } +func validateDeploymentStatus(status *deploymentStatus) error { + if status == nil { + return nil + } + + // The reconciler state machine may persist status.scope (and an in-progress operation) before + // status.container is known, so scope-only status is valid. When the environment or application + // changes, status.scope is updated to the new scope while status.container still references the + // previous container (which the reconciler then deletes), so we do not require status.scope to + // match the container's root scope. + if status.Scope != "" { + if _, err := resources.ParseScope(status.Scope); err != nil { + return fmt.Errorf("invalid status.scope: %w", err) + } + } + + if status.Container == "" { + return nil + } + + if status.Scope == "" { + return fmt.Errorf("status.scope must be set when status.container is set") + } + + parsedContainer, err := resources.ParseResource(status.Container) + if err != nil { + return fmt.Errorf("invalid status.container: %w", err) + } + if !strings.EqualFold(parsedContainer.Type(), applicationsCoreContainersResourceType) { + return fmt.Errorf("status.container type %q is not %q", parsedContainer.Type(), applicationsCoreContainersResourceType) + } + + return nil +} + // ApplyToDeployment applies the configuration and status to a Deployment. // // This should be used before saving the Deployment's state.
pkg/controller/reconciler/annotations_test.go+96 −2 modified@@ -21,6 +21,7 @@ import ( "fmt" "testing" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -31,7 +32,7 @@ func Test_readAnnotations(t *testing.T) { Scope: "/planes/radius/local/resourceGroups/controller-test", Application: "test-application", Environment: "test-environment", - Container: "test-container", + Container: "/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/containers/test-container", Operation: nil, Phrase: deploymentPhraseReady, } @@ -43,6 +44,9 @@ func Test_readAnnotations(t *testing.T) { // so that an unmarshaling error can be triggered. invalidDeploymentStatus := []byte(`{"invalid": "json"`) + _, invalidContainerIDErr := resources.ParseResource("not-a-resource-id") + _, invalidScopeIDErr := resources.ParseScope("not-a-scope") + tests := []struct { name string deployment *appsv1.Deployment @@ -156,11 +160,101 @@ func Test_readAnnotations(t *testing.T) { err: fmt.Errorf("failed to unmarshal status annotation: %w", json.Unmarshal(invalidDeploymentStatus, &deploymentStatus{})), }, + { + name: "status-invalid-container-id", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"not-a-resource-id"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: invalid status.container: %w", invalidContainerIDErr), + }, + { + name: "status-scope-container-mismatch-allowed", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"/planes/radius/local/resourceGroups/other/providers/Applications.Core/containers/test-container"}`, + }, + }, + }, + // The reconciler intentionally produces this transitional state when the environment or + // application changes: status.scope advances to the new scope while status.container still + // references the previous container until it is deleted. + annotations: deploymentAnnotations{ + ConfigurationHash: "", + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/controller-test", + Container: "/planes/radius/local/resourceGroups/other/providers/Applications.Core/containers/test-container", + }, + }, + err: nil, + }, + { + name: "status-container-wrong-resource-type", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test","container":"/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/applications/test-app"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: status.container type %q is not %q", "Applications.Core/applications", applicationsCoreContainersResourceType), + }, + { + name: "status-only-scope-set", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"/planes/radius/local/resourceGroups/controller-test"}`, + }, + }, + }, + annotations: deploymentAnnotations{ + ConfigurationHash: "", + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/controller-test", + }, + }, + err: nil, + }, + { + name: "status-only-container-set", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"container":"/planes/radius/local/resourceGroups/controller-test/providers/Applications.Core/containers/test-container"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: status.scope must be set when status.container is set"), + }, + { + name: "status-invalid-scope-only", + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationRadiusStatus: `{"scope":"not-a-scope"}`, + }, + }, + }, + annotations: deploymentAnnotations{ConfigurationHash: ""}, + err: fmt.Errorf("invalid status annotation: invalid status.scope: %w", invalidScopeIDErr), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { annotations, err := readAnnotations(tt.deployment) - require.Equal(t, tt.err, err) + if tt.err == nil { + require.NoError(t, err) + } else { + require.EqualError(t, err, tt.err.Error()) + } require.Equal(t, tt.annotations, annotations) }) }
pkg/controller/reconciler/const.go+10 −0 modified@@ -45,6 +45,16 @@ const ( // DeploymentFinalizer is the name of the finalizer added to Deployments. DeploymentFinalizer = "radapp.io/deployment-finalizer" + // EventScopeMismatch is emitted when a deployment annotation references a scope outside + // of the deployment's ownership boundary. + EventScopeMismatch = "ScopeMismatch" + + // EventContainerOwnershipMismatch is emitted when a container does not reference + // the source Kubernetes deployment resource. + EventContainerOwnershipMismatch = "ContainerOwnershipMismatch" + + applicationsCoreContainersResourceType = "Applications.Core/containers" + // RecipeFinalizer is the name of the finalizer added to Recipes. RecipeFinalizer = "radapp.io/recipe-finalizer"
pkg/controller/reconciler/deployment_reconciler.go+32 −4 modified@@ -363,7 +363,7 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * func (r *DeploymentReconciler) reconcileDelete(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (ctrl.Result, error) { logger := ucplog.FromContextOrDiscard(ctx) - poller, err := r.startDeleteOperationIfNeeded(ctx, annotations) + poller, err := r.startDeleteOperationIfNeeded(ctx, deployment, annotations) if err != nil { logger.Error(err, "Unable to delete resource.") r.EventRecorder.Event(deployment, corev1.EventTypeWarning, "ResourceError", err.Error()) @@ -415,8 +415,7 @@ func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Con // the old resource and create a new one. logger.Info("Container is already created but is out-of-date") - logger.Info("Starting DELETE operation.") - poller, err := deleteContainer(ctx, r.Radius, annotations.Status.Container) + poller, err := r.startDeleteOperationIfNeeded(ctx, deployment, annotations) if err != nil { return nil, nil, false, err } else if poller != nil { @@ -480,13 +479,42 @@ func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Con return poller, nil, false, nil } -func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], error) { +func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) + + if annotations == nil || annotations.Status == nil { + logger.Info("Container is already deleted (or was never created).") + return nil, nil + } + if annotations.Status.Container == "" { logger.Info("Container is already deleted (or was never created).") return nil, nil } + expectedDeploymentResourceID := makeKubernetesDeploymentResourceID(deployment.Namespace, deployment.Name) + container, err := fetchContainerResource(ctx, r.Radius, annotations.Status.Container) + if clients.Is404Error(err) { + logger.Info("Container was already deleted before cleanup began.", "container", annotations.Status.Container) + annotations.Status.Container = "" + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed to fetch container before delete: %w", err) + } + + if !containerHasResourceReference(&container, expectedDeploymentResourceID) { + logger.Info("Refusing cross-ownership delete attempt.", + "container", annotations.Status.Container, + "expectedDeploymentResource", expectedDeploymentResourceID, + ) + r.EventRecorder.Event(deployment, corev1.EventTypeWarning, EventContainerOwnershipMismatch, + fmt.Sprintf("Container %q does not reference deployment resource %q", annotations.Status.Container, expectedDeploymentResourceID)) + + // Clear status to avoid a persistent reconcile loop on a tampered annotation. + annotations.Status.Container = "" + return nil, nil + } + logger.Info("Starting DELETE operation.") poller, err := deleteContainer(ctx, r.Radius, annotations.Status.Container) if err != nil {
pkg/controller/reconciler/deployment_reconciler_test.go+115 −0 modified@@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/http" "testing" "time" @@ -34,7 +35,9 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" crconfig "sigs.k8s.io/controller-runtime/pkg/config" @@ -95,6 +98,118 @@ func SetupDeploymentTest(t *testing.T) (*mockRadiusClient, client.Client) { return radius, mgr.GetClient() } +func Test_DeploymentReconciler_StartDeleteOperationIfNeeded_OwnershipMismatch_BlocksDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + containerID := "/planes/radius/local/resourceGroups/tenant-b/providers/Applications.Core/containers/other-container" + + radius.Update(func() { + radius.containers[containerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-app"), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{Status: &deploymentStatus{Container: containerID}} + poller, err := reconciler.startDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.Nil(t, poller) + require.Empty(t, annotations.Status.Container) + requireNoDeleteOperation(t, radius, containerID) + + _, err = radius.Containers("/planes/radius/local/resourceGroups/tenant-b").Get(ctx, "other-container", nil) + require.NoError(t, err) +} + +func Test_DeploymentReconciler_StartPutOrDeleteOperationIfNeeded_OwnershipMismatch_BlocksDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + otherContainerID := "/planes/radius/local/resourceGroups/tenant-b/providers/Applications.Core/containers/other-container" + + radius.Update(func() { + radius.containers[otherContainerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-container"), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{ + Configuration: &deploymentConfiguration{}, + Status: &deploymentStatus{ + Scope: "/planes/radius/local/resourceGroups/tenant-a", + Application: "/planes/radius/local/resourceGroups/tenant-a/providers/Applications.Core/applications/current-app", + Container: otherContainerID, + }, + } + + updatePoller, deletePoller, waiting, err := reconciler.startPutOrDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.NotNil(t, updatePoller) + require.Nil(t, deletePoller) + require.False(t, waiting) + requireNoDeleteOperation(t, radius, otherContainerID) + + _, err = radius.Containers("/planes/radius/local/resourceGroups/tenant-b").Get(ctx, "other-container", nil) + require.NoError(t, err) +} + +func Test_DeploymentReconciler_StartDeleteOperationIfNeeded_OwnershipMatch_AllowsDelete(t *testing.T) { + ctx := testcontext.New(t) + radius := NewMockRadiusClient() + reconciler := &DeploymentReconciler{ + Radius: radius, + EventRecorder: record.NewFakeRecorder(10), + } + + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "current-app", Namespace: "current-namespace"}} + containerID := "/planes/radius/local/resourceGroups/tenant-a/providers/Applications.Core/containers/current-app" + + radius.Update(func() { + radius.containers[containerID] = v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: new(makeKubernetesDeploymentResourceID("current-namespace", "current-app")), + }}, + }, + } + }) + + annotations := &deploymentAnnotations{Status: &deploymentStatus{Container: containerID}} + poller, err := reconciler.startDeleteOperationIfNeeded(ctx, deployment, annotations) + require.NoError(t, err) + require.NotNil(t, poller) + require.Equal(t, containerID, annotations.Status.Container) +} + +func requireNoDeleteOperation(t *testing.T, radius *mockRadiusClient, resourceID string) { + t.Helper() + + radius.lock.Lock() + defer radius.lock.Unlock() + + for _, operation := range radius.operations { + require.False(t, operation.Kind == http.MethodDelete && operation.ResourceID == resourceID, "unexpected delete operation for %s", resourceID) + } +} + // Creates a deployment with Radius enabled. // // Then exercises the cleanup path by deleting the deployment.
pkg/controller/reconciler/util.go+51 −2 modified@@ -226,7 +226,7 @@ func fetchResource(ctx context.Context, radius RadiusClient, resourceID string) } func deleteContainer(ctx context.Context, radius RadiusClient, containerID string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { - id, err := resources.Parse(containerID) + id, err := parseContainerResourceID(containerID) if err != nil { return nil, err } @@ -252,8 +252,44 @@ func deleteContainer(ctx context.Context, radius RadiusClient, containerID strin return nil, nil } +func makeKubernetesDeploymentResourceID(namespace string, name string) string { + return "/planes/kubernetes/local/namespaces/" + namespace + "/providers/apps/Deployment/" + name +} + +func containerHasResourceReference(container *corerpv20231001preview.ContainerResource, expectedResourceID string) bool { + if container == nil || container.Properties == nil { + return false + } + + for _, resource := range container.Properties.Resources { + if resource == nil || resource.ID == nil { + continue + } + + if strings.EqualFold(*resource.ID, expectedResourceID) { + return true + } + } + + return false +} + +func fetchContainerResource(ctx context.Context, radius RadiusClient, containerID string) (corerpv20231001preview.ContainerResource, error) { + id, err := parseContainerResourceID(containerID) + if err != nil { + return corerpv20231001preview.ContainerResource{}, err + } + + response, err := radius.Containers(id.RootScope()).Get(ctx, id.Name(), nil) + if err != nil { + return corerpv20231001preview.ContainerResource{}, err + } + + return response.ContainerResource, nil +} + func createOrUpdateContainer(ctx context.Context, radius RadiusClient, containerID string, properties *corerpv20231001preview.ContainerProperties) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { - id, err := resources.Parse(containerID) + id, err := parseContainerResourceID(containerID) if err != nil { return nil, err } @@ -284,6 +320,19 @@ func createOrUpdateContainer(ctx context.Context, radius RadiusClient, container return nil, nil } +func parseContainerResourceID(containerID string) (resources.ID, error) { + id, err := resources.ParseResource(containerID) + if err != nil { + return resources.ID{}, err + } + + if !strings.EqualFold(id.Type(), applicationsCoreContainersResourceType) { + return resources.ID{}, fmt.Errorf("resource type %q is not %q", id.Type(), applicationsCoreContainersResourceType) + } + + return id, nil +} + func generateDeploymentResourceName(resourceId string) (string, error) { id, err := resources.ParseResource(resourceId) if err != nil {
pkg/controller/reconciler/util_test.go+54 −0 modified@@ -3,6 +3,8 @@ package reconciler import ( "testing" + v20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + "github.com/radius-project/radius/pkg/to" "github.com/stretchr/testify/require" ) @@ -86,3 +88,55 @@ func TestConvertToARMJSONParameters(t *testing.T) { }) } } + +func TestMakeKubernetesDeploymentResourceID(t *testing.T) { + got := makeKubernetesDeploymentResourceID("current-namespace", "current-app") + require.Equal(t, "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", got) +} + +func TestContainerHasResourceReference(t *testing.T) { + tests := []struct { + name string + container *v20231001preview.ContainerResource + expectedResource string + want bool + }{ + { + name: "has matching resource reference", + container: &v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: to.Ptr("/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app"), + }}, + }, + }, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: true, + }, + { + name: "missing matching reference", + container: &v20231001preview.ContainerResource{ + Properties: &v20231001preview.ContainerProperties{ + Resources: []*v20231001preview.ResourceReference{{ + ID: to.Ptr("/planes/kubernetes/local/namespaces/other-namespace/providers/apps/Deployment/other-app"), + }}, + }, + }, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: false, + }, + { + name: "nil container", + container: nil, + expectedResource: "/planes/kubernetes/local/namespaces/current-namespace/providers/apps/Deployment/current-app", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containerHasResourceReference(tt.container, tt.expectedResource) + require.Equal(t, tt.want, got) + }) + } +}
pkg/recipes/configloader/environment.go+1 −3 modified@@ -213,9 +213,7 @@ func getConfigurationV20250801(ctx context.Context, environment *v20250801previe // registry credentials (key=`token`). if len(tfProps.Terraformrc.Credentials) > 0 { config.RecipeConfig.Terraform.Credentials = make(map[string]datamodel.TerraformCredentialConfig, len(tfProps.Terraformrc.Credentials)) - for host, cred := range tfProps.Terraformrc.Credentials { - config.RecipeConfig.Terraform.Credentials[host] = cred - } + maps.Copy(config.RecipeConfig.Terraform.Credentials, tfProps.Terraformrc.Credentials) } // Map env vars into the legacy shape.
Vulnerability mechanics
Root cause
"The Radius controller deserializes user-controllable JSON from the `radapp.io/status` annotation on Deployments without validating that the referenced resource IDs belong to the current tenant."
Attack vector
An attacker with permission to modify Deployment annotations patches the `radapp.io/status` annotation on their own Deployment, injecting a JSON payload that points to a victim's container resource ID in a different tenant. The Radius controller reads this annotation, deserializes it without validating tenant ownership [CWE-20], and uses its own high-privilege credentials to issue a DELETE request to the Radius API for the victim's container [CWE-441]. The attack requires only Deployment edit permission and knowledge of the target resource ID. [ref_id=1][ref_id=2]
Affected code
The vulnerability originates in `pkg/controller/reconciler/annotations.go:110-119` where the `radapp.io/status` annotation is deserialized without tenant-scope validation, and the sink is `pkg/controller/reconciler/deployment_reconciler.go:491` where the unvalidated container ID is passed directly to `deleteContainer`. [ref_id=1][ref_id=2]
What the fix does
The advisory recommends adding a `validateContainerScope` function in `annotations.go` that compares the tenant scope extracted from the Deployment's own metadata against the scope embedded in the container resource ID from the annotation. If the scopes do not match, the controller should reject the operation with an error. This prevents the controller from acting on a container ID that belongs to a different tenant. [ref_id=1][ref_id=2]
Preconditions
- authAttacker must have permission to modify Deployment annotations in a Kubernetes namespace
- inputAttacker must know the target container's Radius resource ID (e.g., `/planes/radius/local/resourceGroups/tenant-b/providers/Applications.Core/containers/victim-container`)
- configThe Radius controller must be running in a multi-tenant topology where it reconciles Deployments across resource groups owned by different teams
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.