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")
+}