-
Notifications
You must be signed in to change notification settings - Fork 4
fix: reject ambiguous inline key-auth config that bypasses duplicate check #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| package v1 | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
|
|
@@ -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", | ||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 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 |
||
| } | ||
| 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 | ||
| } | ||
There was a problem hiding this comment.
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.
validateDuplicateKeyAuthCredentialsloops over every existing Consumer sharing the gateway and doesextractKeyAuthKeys(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:
configis preserve-unknown-fields so the apiserver stores{"key":123}verbatim, and the webhook shipsfailurePolicy: Ignore, so anything created while the controller is down lands unvalidated.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.