This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new be19f90a fix: suppress cross-namespace Secret existence oracle in 
Consumer webhook (#2806)
be19f90a is described below

commit be19f90a90c82073f29afbd52bfcb9e86c728a78
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Wed Jul 29 11:49:18 2026 +0800

    fix: suppress cross-namespace Secret existence oracle in Consumer webhook 
(#2806)
---
 internal/controller/consumer_controller.go   |  22 +---
 internal/controller/utils.go                 |  39 +++++++
 internal/webhook/v1/consumer_webhook.go      |  34 +++++-
 internal/webhook/v1/consumer_webhook_test.go | 166 +++++++++++++++++++++++++++
 4 files changed, 244 insertions(+), 17 deletions(-)

diff --git a/internal/controller/consumer_controller.go 
b/internal/controller/consumer_controller.go
index 0f265be5..ece20ec1 100644
--- a/internal/controller/consumer_controller.go
+++ b/internal/controller/consumer_controller.go
@@ -35,7 +35,6 @@ 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"
@@ -262,21 +261,12 @@ func (r *ConsumerReconciler) processSpec(ctx 
context.Context, tctx *provider.Tra
                        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 {
+               secretNN := types.NamespacedName{Namespace: ns, Name: 
credential.SecretRef.Name}
+               permitted, err := CheckConsumerSecretRef(ctx, r.Client, 
consumer.GetNamespace(), secretNN)
+               if err != nil {
+                       return err
+               }
+               if !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",
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index 3c36da1c..8463113d 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -1494,6 +1494,45 @@ func checkReferenceGrant(ctx context.Context, cli 
client.Client, obj v1beta1.Ref
        return false
 }
 
+// CheckConsumerSecretRef reports whether a Consumer in fromNamespace may 
reference
+// the Secret at secretNN, honoring ReferenceGrant for cross-namespace 
references.
+// A non-nil error means the grant lookup itself failed (API server, RBAC, 
cache);
+// that is distinct from a permitted value of false, which means no 
ReferenceGrant
+// allows the reference. Callers must not treat a lookup failure as "denied".
+func CheckConsumerSecretRef(ctx context.Context, cli client.Client, 
fromNamespace string, secretNN k8stypes.NamespacedName) (bool, error) {
+       if secretNN.Namespace == "" || secretNN.Namespace == fromNamespace {
+               return true, nil
+       }
+       if !GetEnableReferenceGrant() {
+               return false, nil
+       }
+
+       var grantList v1beta1.ReferenceGrantList
+       if err := cli.List(ctx, &grantList, 
client.InNamespace(secretNN.Namespace)); err != nil {
+               return false, err
+       }
+
+       from := v1beta1.ReferenceGrantFrom{
+               Group:     v1beta1.Group(v1alpha1.GroupVersion.Group),
+               Kind:      types.KindConsumer,
+               Namespace: v1beta1.Namespace(fromNamespace),
+       }
+       for _, grant := range grantList.Items {
+               for _, f := range grant.Spec.From {
+                       if f != from {
+                               continue
+                       }
+                       for _, to := range grant.Spec.To {
+                               if to.Group == corev1.GroupName && 
string(to.Kind) == types.KindSecret &&
+                                       (to.Name == nil || string(*to.Name) == 
secretNN.Name) {
+                                       return true, nil
+                               }
+                       }
+               }
+       }
+       return false, nil
+}
+
 func ListRequests(
        ctx context.Context,
        c client.Client,
diff --git a/internal/webhook/v1/consumer_webhook.go 
b/internal/webhook/v1/consumer_webhook.go
index a19b5782..0f141e85 100644
--- a/internal/webhook/v1/consumer_webhook.go
+++ b/internal/webhook/v1/consumer_webhook.go
@@ -132,6 +132,23 @@ func (v *ConsumerCustomValidator) collectWarnings(ctx 
context.Context, consumer
                }
                visited[nn] = struct{}{}
 
+               // Don't probe cross-namespace Secrets that no ReferenceGrant 
permits: the
+               // found/not-found warning difference would leak Secret 
existence across
+               // namespaces. Emit a uniform message and skip the lookup. On a 
lookup
+               // failure, skip the probe too but warn neutrally, without 
implying a grant
+               // is missing.
+               if namespace != defaultNamespace {
+                       permitted, err := 
controller.CheckConsumerSecretRef(ctx, v.Client, defaultNamespace, nn)
+                       if err != nil {
+                               warnings = append(warnings, fmt.Sprintf("Could 
not verify authorization for referenced Secret '%s/%s'", nn.Namespace, nn.Name))
+                               continue
+                       }
+                       if !permitted {
+                               warnings = append(warnings, 
fmt.Sprintf("Referenced Secret '%s/%s' is not accessible from this Consumer 
without a ReferenceGrant", nn.Namespace, nn.Name))
+                               continue
+                       }
+               }
+
                warnings = append(warnings, v.checker.Secret(ctx, 
reference.SecretRef{
                        Object:         consumer,
                        NamespacedName: nn,
@@ -212,8 +229,23 @@ func (v *ConsumerCustomValidator) extractCredentialKey(ctx 
context.Context, cons
                        namespace = *credential.SecretRef.Namespace
                }
 
+               nn := types.NamespacedName{Namespace: namespace, Name: 
credential.SecretRef.Name}
+               // Don't read a cross-namespace Secret that no ReferenceGrant 
permits:
+               // duplicate detection would otherwise reveal its existence and 
key. Treat
+               // it as absent; the reference is denied later during admission 
anyway. A
+               // lookup failure is surfaced so admission fails closed rather 
than admitting.
+               if namespace != consumer.Namespace {
+                       permitted, err := 
controller.CheckConsumerSecretRef(ctx, v.Client, consumer.Namespace, nn)
+                       if err != nil {
+                               return "", err
+                       }
+                       if !permitted {
+                               return "", nil
+                       }
+               }
+
                var secret corev1.Secret
-               err := v.Client.Get(ctx, types.NamespacedName{Namespace: 
namespace, Name: credential.SecretRef.Name}, &secret)
+               err := v.Client.Get(ctx, nn, &secret)
                if err != nil {
                        if k8serrors.IsNotFound(err) {
                                return "", nil
diff --git a/internal/webhook/v1/consumer_webhook_test.go 
b/internal/webhook/v1/consumer_webhook_test.go
index 66191b57..b9cfbcb6 100644
--- a/internal/webhook/v1/consumer_webhook_test.go
+++ b/internal/webhook/v1/consumer_webhook_test.go
@@ -17,15 +17,19 @@ package v1
 
 import (
        "context"
+       "fmt"
        "testing"
 
        "github.com/stretchr/testify/require"
        corev1 "k8s.io/api/core/v1"
        apiextensionsv1 
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+       apierrors "k8s.io/apimachinery/pkg/api/errors"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/runtime"
        clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
+       "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
        "sigs.k8s.io/gateway-api/apis/v1beta1"
 
@@ -93,6 +97,41 @@ func buildConsumerValidator(t *testing.T, objects 
...runtime.Object) *ConsumerCu
        return NewConsumerCustomValidator(builder.Build())
 }
 
+// buildConsumerValidatorWithInterceptor is buildConsumerValidator with client
+// interceptor funcs, used to simulate API server / cache failures.
+func buildConsumerValidatorWithInterceptor(t *testing.T, funcs 
interceptor.Funcs, objects ...runtime.Object) *ConsumerCustomValidator {
+       t.Helper()
+
+       scheme := runtime.NewScheme()
+       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{
+                       ObjectMeta: metav1.ObjectMeta{Name: 
"apisix-gateway-class"},
+                       Spec: gatewayv1.GatewayClassSpec{
+                               ControllerName: 
gatewayv1.GatewayController(config.ControllerConfig.ControllerName),
+                       },
+               },
+               &gatewayv1.Gateway{
+                       ObjectMeta: metav1.ObjectMeta{Name: "test-gateway", 
Namespace: "default"},
+                       Spec: gatewayv1.GatewaySpec{
+                               GatewayClassName: 
gatewayv1.ObjectName("apisix-gateway-class"),
+                       },
+               },
+       }
+       allObjects := append(managed, objects...)
+       builder := fake.NewClientBuilder().
+               WithScheme(scheme).
+               WithRuntimeObjects(allObjects...).
+               WithIndex(&apisixv1alpha1.Consumer{}, 
indexer.ConsumerGatewayRef, indexer.ConsumerGatewayRefIndexFunc).
+               WithInterceptorFuncs(funcs)
+
+       return NewConsumerCustomValidator(builder.Build())
+}
+
 func TestConsumerValidator_MissingSecretDefaultNamespace(t *testing.T) {
        consumer := &apisixv1alpha1.Consumer{
                ObjectMeta: metav1.ObjectMeta{
@@ -175,6 +214,85 @@ func 
TestConsumerValidator_CrossNamespaceSecretRefDeniedWithoutGrant(t *testing.
        require.Contains(t, err.Error(), "not permitted by any ReferenceGrant")
 }
 
+// The rejection above must not double as an existence oracle: a 
cross-namespace ref
+// that no ReferenceGrant permits produces the same response whether or not the
+// Secret exists.
+func TestConsumerValidator_CrossNamespaceSecretOracleSuppressed(t *testing.T) {
+       enableReferenceGrant(t)
+       ns := authNS
+       newConsumer := func() *apisixv1alpha1.Consumer {
+               return &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 present in the foreign namespace, no ReferenceGrant 
permitting the ref.
+       present := buildConsumerValidator(t, &corev1.Secret{
+               ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: 
authNS},
+       })
+       presentWarnings, presentErr := 
present.ValidateCreate(context.Background(), newConsumer())
+       require.Error(t, presentErr)
+
+       // Same request, Secret absent.
+       absent := buildConsumerValidator(t)
+       absentWarnings, absentErr := 
absent.ValidateCreate(context.Background(), newConsumer())
+       require.Error(t, absentErr)
+
+       // Identical response either way: no existence oracle.
+       require.Equal(t, absentWarnings, presentWarnings)
+       require.Equal(t, absentErr.Error(), presentErr.Error())
+       require.Len(t, presentWarnings, 1)
+       require.Contains(t, presentWarnings[0], "Referenced Secret 
'auth/jwt-secret' is not accessible from this Consumer without a 
ReferenceGrant")
+}
+
+// A failed ReferenceGrant lookup must not masquerade as "no grant": the Secret
+// probe is skipped and the warning stays neutral, never claiming a grant is 
missing.
+func TestConsumerValidator_CrossNamespaceSecretGrantLookupError(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},
+                       }},
+               },
+       }
+
+       // The referenced Secret exists; only the ReferenceGrant List fails.
+       validator := buildConsumerValidatorWithInterceptor(t,
+               interceptor.Funcs{
+                       List: func(ctx context.Context, c client.WithWatch, 
list client.ObjectList, opts ...client.ListOption) error {
+                               if _, ok := list.(*v1beta1.ReferenceGrantList); 
ok {
+                                       return 
apierrors.NewInternalError(fmt.Errorf("api server unavailable"))
+                               }
+                               return c.List(ctx, list, opts...)
+                       },
+               },
+               &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: 
"jwt-secret", Namespace: authNS}},
+       )
+
+       warnings, _ := validator.ValidateCreate(context.Background(), consumer)
+       require.Len(t, warnings, 1)
+       require.Contains(t, warnings[0], "Could not verify authorization for 
referenced Secret 'auth/jwt-secret'")
+       require.NotContains(t, warnings[0], "without a ReferenceGrant")
+}
+
 func TestConsumerValidator_NoWarnings(t *testing.T) {
        ns := authNS
        consumer := &apisixv1alpha1.Consumer{
@@ -255,3 +373,51 @@ func 
TestConsumerValidator_DenyDuplicateKeyAuthCredential(t *testing.T) {
        // The credential value must never leak into the error returned to 
clients/logs.
        require.NotContains(t, err.Error(), "shared-key")
 }
+
+// The duplicate-key check must not become a cross-namespace oracle: a key-auth
+// credential whose secretRef points across namespaces without a ReferenceGrant
+// must respond the same whether the Secret exists with a colliding key or is
+// absent, and must never leak the duplicate-key error.
+func TestConsumerValidator_CrossNamespaceKeyAuthDuplicateOracleSuppressed(t 
*testing.T) {
+       enableReferenceGrant(t)
+       ns := authNS
+
+       existing := &apisixv1alpha1.Consumer{
+               ObjectMeta: metav1.ObjectMeta{Name: "existing", Namespace: 
"default"},
+               Spec: apisixv1alpha1.ConsumerSpec{
+                       GatewayRef: apisixv1alpha1.GatewayRef{Name: 
"test-gateway"},
+                       Credentials: []apisixv1alpha1.Credential{{
+                               Type:   "key-auth",
+                               Config: apiextensionsv1.JSON{Raw: 
[]byte(`{"key":"shared-key"}`)},
+                       }},
+               },
+       }
+       newConsumer := func() *apisixv1alpha1.Consumer {
+               return &apisixv1alpha1.Consumer{
+                       ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: 
"default"},
+                       Spec: apisixv1alpha1.ConsumerSpec{
+                               GatewayRef: apisixv1alpha1.GatewayRef{Name: 
"test-gateway"},
+                               Credentials: []apisixv1alpha1.Credential{{
+                                       Type:      "key-auth",
+                                       SecretRef: 
&apisixv1alpha1.SecretReference{Name: "key-secret", Namespace: &ns},
+                               }},
+                       },
+               }
+       }
+
+       // Cross-namespace Secret exists and holds the colliding key, but no 
grant permits it.
+       collides := buildConsumerValidator(t, existing, &corev1.Secret{
+               ObjectMeta: metav1.ObjectMeta{Name: "key-secret", Namespace: 
authNS},
+               Data:       map[string][]byte{"key": []byte("shared-key")},
+       })
+       collidesWarnings, collidesErr := 
collides.ValidateCreate(context.Background(), newConsumer())
+
+       // Same request, Secret absent.
+       absent := buildConsumerValidator(t, existing)
+       absentWarnings, absentErr := 
absent.ValidateCreate(context.Background(), newConsumer())
+
+       // Identical response either way, and no duplicate-key error ever 
surfaces.
+       require.Equal(t, absentWarnings, collidesWarnings)
+       require.Equal(t, fmt.Sprint(absentErr), fmt.Sprint(collidesErr))
+       require.NotContains(t, fmt.Sprint(collidesErr), "duplicate key-auth 
credential key")
+}

Reply via email to