Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 103 additions & 8 deletions internal/webhook/v1/consumer_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package v1

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -227,15 +228,109 @@ func (v *ConsumerCustomValidator) extractCredentialKey(ctx context.Context, cons
return "", nil
}

var cfg struct {
Key string `json:"key"`
key, err := parseInlineKeyAuthKey(credential.Config.Raw)
if err != nil {
return "", fmt.Errorf("invalid key-auth credential config for Consumer %s/%s: %w",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part I think is a real regression: it turns a per-credential skip into a hard admission failure, and the caller applies that to other objects too.

validateDuplicateKeyAuthCredentials loops over every existing Consumer sharing the gateway and does extractKeyAuthKeys(existing) -> return err. So one stored Consumer with e.g. {"key":123} now denies create/update of every unrelated Consumer on that gateway, with an error naming a different object.

That state is reachable, unlike the duplicate-key case: config is preserve-unknown-fields so the apiserver stores {"key":123} verbatim, and the webhook ships failurePolicy: Ignore, so anything created while the controller is down lands unvalidated.

// existing: {"key":123}   newcomer: {"key":"my-own-unique-key"}, same gateway, no collision
validator := buildConsumerValidator(t, existing)
_, err := validator.ValidateCreate(context.Background(), newcomer)
require.NoError(t, err)

Passes on master, fails here with invalid key-auth credential config for Consumer default/legacy: key-auth credential "key" must be a string.

Could the existing-consumers loop stay best-effort — skip a credential it can't read, as today — and hard-deny only on the incoming object? That keeps the stricter validation where it's actionable without letting one legacy object wedge a whole gateway.

consumer.Namespace, consumer.Name, err)
}
return key, nil
}

// parseInlineKeyAuthKey extracts the key-auth "key" from an inline credential
// config the same way downstream cjson does: exact-case, string-valued,
// last-wins. Ambiguous configs that Go's struct decoder would silently reject
// while cjson still resolves to a live key (duplicate "key" members, or a
// non-string "key") are returned as errors so they can't bypass the duplicate
// check. Genuinely malformed JSON that cjson also rejects returns ("", nil) so
// existing consumers with broken config are skipped, not suddenly denied.
func parseInlineKeyAuthKey(raw []byte) (string, error) {
// cjson rejects malformed input (truncation, trailing data, multiple
// top-level values); mirror that by skipping anything that is not exactly
// one well-formed JSON value. Duplicate keys stay valid here and are caught
// by the token walk below.
if !json.Valid(raw) {
return "", nil
}

dec := json.NewDecoder(bytes.NewReader(raw))

// Top level must be an object, else there is no usable key.
tok, err := dec.Token()
if err != nil {
return "", nil
}
if err := json.Unmarshal(credential.Config.Raw, &cfg); err != nil {
// Malformed JSON is not a hard error: skip duplicate detection for this
// credential so existing consumers with bad config are not suddenly denied.
consumerLog.V(1).Info("skipping duplicate key-auth check: malformed credential config",
"consumer", consumer.Name, "error", err)
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return "", nil
}
return cfg.Key, nil

var (
key string
keyCount int
)
for dec.More() {
nameTok, err := dec.Token()
if err != nil {
return "", nil
}
name, ok := nameTok.(string)
if !ok {
return "", nil
}
if name != "key" {
if err := skipJSONValue(dec); err != nil {
return "", nil
}
continue
}

keyCount++
valTok, err := dec.Token()
if err != nil {
return "", nil
}
switch val := valTok.(type) {
case string:
key = val
case nil:
// null key: no usable value, but still counts for dup detection.
default:
// number/bool/object/array: cjson would deliver a value here while
// Go's struct decoder errors and skips. Reject instead.
return "", fmt.Errorf("key-auth credential \"key\" must be a string")
}
}

if keyCount > 1 {
return "", fmt.Errorf("key-auth credential config has duplicate \"key\" members")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as on the OSS half (apache/apisix-ingress-controller#2807) — the two diffs are identical, so both apply here.

I don't think this branch can fire in a real cluster. The apiserver collapses duplicate JSON object keys (last-wins) while decoding the request — before admission webhooks are called and before storage — so credential.Config.Raw never holds two key members.

I checked at the raw-byte level rather than trusting a round-trip through a JSON parser (which would collapse duplicates itself and hide the answer): registered a probe validating webhook on consumers, dumped the unparsed AdmissionReview body, and POSTed the PoC through the raw REST endpoint.

$ kubectl create --raw ".../consumers" -f -   # {"key":123,"key":"victims-key"}
Warning: duplicate field "spec.credentials[0].config.key"

# raw bytes the webhook received:
{"credentials":[{"config":{"key":"victims-key"},"name":"c1","type":"key-auth"}],...
#   occurrences of "key": -> 1     contains "key":123 -> False

The persisted object is that same normalized form, so the controller and cjson downstream see it too. So the old struct decoder parses the PoC fine and the duplicate check runs normally — the webhook and cjson never disagree.

Do you have a PoC that reaches the webhook with the duplicate intact? If it was built by calling ValidateCreate directly with hand-written Raw bytes, that would explain it — those bytes can't come out of the apiserver.

}
return key, nil
}

// skipJSONValue consumes a single JSON value (scalar or a whole object/array)
// from the decoder so the token stream stays aligned.
func skipJSONValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
delim, ok := tok.(json.Delim)
if !ok || (delim != '{' && delim != '[') {
return nil
}
depth := 1
for depth > 0 {
t, err := dec.Token()
if err != nil {
return err
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
return nil
}
67 changes: 67 additions & 0 deletions internal/webhook/v1/consumer_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,70 @@ func TestConsumerValidator_DenyDuplicateKeyAuthCredential(t *testing.T) {
require.Contains(t, err.Error(), `duplicate key-auth credential key "shared-key"`)
require.Contains(t, err.Error(), "default/existing")
}

// A duplicate-key inline config ({"key":123,"key":"K"}) is unreadable to Go's
// struct decoder but resolves to "K" downstream via cjson. The webhook must
// reject it instead of silently skipping the duplicate check.
func TestConsumerValidator_DenyDuplicateKeyAuthCredential_ParserDivergence(t *testing.T) {
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":"victims-key"}`)},
}},
},
}
consumer := &apisixv1alpha1.Consumer{
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"},
Spec: apisixv1alpha1.ConsumerSpec{
GatewayRef: apisixv1alpha1.GatewayRef{Name: "test-gateway"},
Credentials: []apisixv1alpha1.Credential{{
Type: "key-auth",
Config: apiextensionsv1.JSON{Raw: []byte(`{"key":123,"key":"victims-key"}`)},
}},
},
}

validator := buildConsumerValidator(t, existing)

_, err := validator.ValidateCreate(context.Background(), consumer)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid key-auth credential config")
}

func TestParseInlineKeyAuthKey(t *testing.T) {
tests := []struct {
name string
raw string
wantKey string
wantErr bool
}{
{name: "plain string key", raw: `{"key":"K"}`, wantKey: "K"},
{name: "extra fields ignored", raw: `{"key":"K","foo":{"a":1}}`, wantKey: "K"},
{name: "duplicate key members", raw: `{"key":"a","key":"b"}`, wantErr: true},
{name: "number then string (divergence PoC)", raw: `{"key":123,"key":"K"}`, wantErr: true},
{name: "non-string key", raw: `{"key":123}`, wantErr: true},
{name: "object key", raw: `{"key":{"nested":1}}`, wantErr: true},
{name: "null key skipped", raw: `{"key":null}`, wantKey: ""},
{name: "no key member", raw: `{"foo":"bar"}`, wantKey: ""},
{name: "exact-case only, Key ignored", raw: `{"Key":"K"}`, wantKey: ""},
{name: "malformed json skipped", raw: `{"key":`, wantKey: ""},
{name: "truncated object skipped", raw: `{"key":"K"`, wantKey: ""},
{name: "multiple top-level values skipped", raw: `{"key":"K"}{"key":"X"}`, wantKey: ""},
{name: "trailing garbage skipped", raw: `{"key":"K"} junk`, wantKey: ""},
{name: "non-object skipped", raw: `["key","K"]`, wantKey: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, err := parseInlineKeyAuthKey([]byte(tt.raw))
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantKey, key)
})
}
}
Loading