From 7b255208b86942c700513681774989a213f0644b Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Fri, 17 Jul 2026 14:10:51 +0545 Subject: [PATCH 1/2] fix: enforce ReferenceGrant on cross-namespace Consumer SecretRef A v1alpha1 Consumer credential secretRef carries an attacker-settable namespace. The reconciler honored it and read the Secret with the controller's cluster-wide RBAC, no ReferenceGrant check, binding a foreign namespace's auth material to the consumer identity (confused deputy). The route controllers already gate cross-namespace refs via checkReferenceGrant; apply the same gate in the Consumer reconciler and refuse the reference when no grant permits it. --- internal/controller/consumer_controller.go | 22 +++ .../controller/consumer_controller_test.go | 148 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/controller/consumer_controller_test.go diff --git a/internal/controller/consumer_controller.go b/internal/controller/consumer_controller.go index cfb1b296..62a5d8b5 100644 --- a/internal/controller/consumer_controller.go +++ b/internal/controller/consumer_controller.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1beta1" "github.com/apache/apisix-ingress-controller/api/v1alpha1" "github.com/apache/apisix-ingress-controller/internal/controller/config" @@ -253,6 +254,27 @@ func (r *ConsumerReconciler) processSpec(ctx context.Context, tctx *provider.Tra if credential.SecretRef.Namespace != nil { ns = *credential.SecretRef.Namespace } + // A cross-namespace SecretRef needs a ReferenceGrant, same as routes. + secretNS := gatewayv1.Namespace(ns) + if permitted := checkReferenceGrant(ctx, + r.Client, + v1beta1.ReferenceGrantFrom{ + Group: v1beta1.Group(v1alpha1.GroupVersion.Group), + Kind: v1beta1.Kind(internaltypes.KindConsumer), + Namespace: v1beta1.Namespace(consumer.GetNamespace()), + }, + gatewayv1.ObjectReference{ + Group: corev1.GroupName, + Kind: KindSecret, + Name: gatewayv1.ObjectName(credential.SecretRef.Name), + Namespace: &secretNS, + }, + ); !permitted { + r.Log.Error(nil, "cross-namespace secret reference not permitted by any ReferenceGrant", + "consumer", utils.NamespacedName(consumer), "secret", client.ObjectKey{Namespace: ns, Name: credential.SecretRef.Name}) + return fmt.Errorf("cross-namespace secret reference from Consumer %s/%s to Secret %s/%s is not permitted by any ReferenceGrant", + consumer.GetNamespace(), consumer.GetName(), ns, credential.SecretRef.Name) + } secret := corev1.Secret{} if err := r.Get(ctx, client.ObjectKey{ Name: credential.SecretRef.Name, diff --git a/internal/controller/consumer_controller_test.go b/internal/controller/consumer_controller_test.go new file mode 100644 index 00000000..4194d8d9 --- /dev/null +++ b/internal/controller/consumer_controller_test.go @@ -0,0 +1,148 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package controller + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1beta1" + + "github.com/apache/apisix-ingress-controller/api/v1alpha1" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +const ( + consumerNS = "team-a" + secretNS = "team-b" +) + +func buildConsumerReconciler(t *testing.T, objs ...runtime.Object) *ConsumerReconciler { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, gatewayv1.Install(scheme)) + require.NoError(t, v1beta1.Install(scheme)) + + cli := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() + return &ConsumerReconciler{Client: cli, Log: logr.Discard()} +} + +func crossNamespaceConsumer() *v1alpha1.Consumer { + target := secretNS + return &v1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{Name: "attacker", Namespace: consumerNS}, + Spec: v1alpha1.ConsumerSpec{ + Credentials: []v1alpha1.Credential{{ + Name: "cred", + Type: "key-auth", + SecretRef: &v1alpha1.SecretReference{Name: "victim-secret", Namespace: &target}, + }}, + }, + } +} + +func victimSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "victim-secret", Namespace: secretNS}, + Data: map[string][]byte{"key": []byte("victim-key")}, + } +} + +func secretGrant() *v1beta1.ReferenceGrant { + return &v1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", Namespace: secretNS}, + Spec: v1beta1.ReferenceGrantSpec{ + From: []v1beta1.ReferenceGrantFrom{{ + Group: v1beta1.Group(v1alpha1.GroupVersion.Group), + Kind: "Consumer", + Namespace: consumerNS, + }}, + To: []v1beta1.ReferenceGrantTo{{ + Group: "", + Kind: "Secret", + }}, + }, + } +} + +// Without a ReferenceGrant the cross-namespace secret must not be bound. +func TestProcessSpec_CrossNamespaceSecretRef_DeniedWithoutGrant(t *testing.T) { + SetEnableReferenceGrant(true) + defer SetEnableReferenceGrant(false) + + r := buildConsumerReconciler(t, victimSecret()) + consumer := crossNamespaceConsumer() + tctx := provider.NewDefaultTranslateContext(context.Background()) + + err := r.processSpec(context.Background(), tctx, consumer) + require.Error(t, err) + require.Empty(t, tctx.Secrets, "foreign secret must not be loaded without a ReferenceGrant") +} + +// A matching ReferenceGrant permits the cross-namespace secret. +func TestProcessSpec_CrossNamespaceSecretRef_AllowedWithGrant(t *testing.T) { + SetEnableReferenceGrant(true) + defer SetEnableReferenceGrant(false) + + r := buildConsumerReconciler(t, victimSecret(), secretGrant()) + consumer := crossNamespaceConsumer() + tctx := provider.NewDefaultTranslateContext(context.Background()) + + err := r.processSpec(context.Background(), tctx, consumer) + require.NoError(t, err) + require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: secretNS, Name: "victim-secret"}) +} + +// Same-namespace SecretRef needs no grant. +func TestProcessSpec_SameNamespaceSecretRef_Allowed(t *testing.T) { + SetEnableReferenceGrant(true) + defer SetEnableReferenceGrant(false) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "local-secret", Namespace: consumerNS}, + Data: map[string][]byte{"key": []byte("local-key")}, + } + r := buildConsumerReconciler(t, secret) + consumer := &v1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: consumerNS}, + Spec: v1alpha1.ConsumerSpec{ + Credentials: []v1alpha1.Credential{{ + Name: "cred", + Type: "key-auth", + SecretRef: &v1alpha1.SecretReference{Name: "local-secret"}, + }}, + }, + } + tctx := provider.NewDefaultTranslateContext(context.Background()) + + err := r.processSpec(context.Background(), tctx, consumer) + require.NoError(t, err) + require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: consumerNS, Name: "local-secret"}) +} From 5e59ede1cbb28bb908e93ff32186f6822c05ed20 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Fri, 17 Jul 2026 16:32:01 +0545 Subject: [PATCH 2/2] test: update Consumer webhook tests for cross-namespace grant enforcement The Consumer admission validator shares processSpec via PrepareConsumerForValidation, so the ReferenceGrant gate now also fail-closes at admission. Update the two cross-namespace webhook tests to permit the reference via a ReferenceGrant, and add a test asserting a cross-namespace secretRef without a grant is denied at admission. --- internal/webhook/v1/consumer_webhook_test.go | 69 ++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/internal/webhook/v1/consumer_webhook_test.go b/internal/webhook/v1/consumer_webhook_test.go index 4dc32b84..f6a2b810 100644 --- a/internal/webhook/v1/consumer_webhook_test.go +++ b/internal/webhook/v1/consumer_webhook_test.go @@ -27,12 +27,40 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1beta1" apisixv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" + "github.com/apache/apisix-ingress-controller/internal/controller" "github.com/apache/apisix-ingress-controller/internal/controller/config" "github.com/apache/apisix-ingress-controller/internal/controller/indexer" ) +// authNS is a foreign namespace used for cross-namespace secretRef tests. +const authNS = "auth" + +// enableReferenceGrant turns on the cross-namespace ReferenceGrant gate for the +// duration of a test and resets it afterwards. +func enableReferenceGrant(t *testing.T) { + t.Helper() + controller.SetEnableReferenceGrant(true) + t.Cleanup(func() { controller.SetEnableReferenceGrant(false) }) +} + +// consumerToSecretGrant permits Consumers in fromNS to reference Secrets in grantNS. +func consumerToSecretGrant(grantNS, fromNS string) *v1beta1.ReferenceGrant { + return &v1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", Namespace: grantNS}, + Spec: v1beta1.ReferenceGrantSpec{ + From: []v1beta1.ReferenceGrantFrom{{ + Group: v1beta1.Group(apisixv1alpha1.GroupVersion.Group), + Kind: "Consumer", + Namespace: v1beta1.Namespace(fromNS), + }}, + To: []v1beta1.ReferenceGrantTo{{Group: "", Kind: "Secret"}}, + }, + } +} + func buildConsumerValidator(t *testing.T, objects ...runtime.Object) *ConsumerCustomValidator { t.Helper() @@ -40,6 +68,7 @@ func buildConsumerValidator(t *testing.T, objects ...runtime.Object) *ConsumerCu require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, apisixv1alpha1.AddToScheme(scheme)) require.NoError(t, gatewayv1.Install(scheme)) + require.NoError(t, v1beta1.Install(scheme)) managed := []runtime.Object{ &gatewayv1.GatewayClass{ @@ -90,7 +119,7 @@ func TestConsumerValidator_MissingSecretDefaultNamespace(t *testing.T) { } func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) { - ns := "auth" + ns := authNS consumer := &apisixv1alpha1.Consumer{ ObjectMeta: metav1.ObjectMeta{ Name: "demo", @@ -108,7 +137,8 @@ func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) { }, } - validator := buildConsumerValidator(t) + enableReferenceGrant(t) + validator := buildConsumerValidator(t, consumerToSecretGrant(authNS, "default")) warnings, err := validator.ValidateCreate(context.Background(), consumer) require.NoError(t, err) @@ -116,8 +146,37 @@ func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) { require.Contains(t, warnings[0], "Referenced Secret 'auth/jwt-secret' not found") } +// A cross-namespace secretRef without a permitting ReferenceGrant is rejected at admission. +func TestConsumerValidator_CrossNamespaceSecretRefDeniedWithoutGrant(t *testing.T) { + enableReferenceGrant(t) + ns := authNS + consumer := &apisixv1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv1alpha1.ConsumerSpec{ + GatewayRef: apisixv1alpha1.GatewayRef{Name: "test-gateway"}, + Credentials: []apisixv1alpha1.Credential{{ + Type: "jwt-auth", + SecretRef: &apisixv1alpha1.SecretReference{ + Name: "jwt-secret", + Namespace: &ns, + }, + }}, + }, + } + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: authNS}} + validator := buildConsumerValidator(t, secret) + + _, err := validator.ValidateCreate(context.Background(), consumer) + require.Error(t, err) + require.Contains(t, err.Error(), "not permitted by any ReferenceGrant") +} + func TestConsumerValidator_NoWarnings(t *testing.T) { - ns := "auth" + ns := authNS consumer := &apisixv1alpha1.Consumer{ ObjectMeta: metav1.ObjectMeta{ Name: "demo", @@ -140,9 +199,11 @@ func TestConsumerValidator_NoWarnings(t *testing.T) { }, } + enableReferenceGrant(t) objs := []runtime.Object{ - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: "auth"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: authNS}}, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "key-secret", Namespace: "default"}}, + consumerToSecretGrant(authNS, "default"), } validator := buildConsumerValidator(t, objs...)