From 4ed9e91095911814117b8b17c0cf8212e4d4eb9e Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 11 Nov 2026 16:39:40 +0000 Subject: [PATCH] fix(cli): reject misplaced source auth keys; stop guessing HMAC defaults for provider types Auth fields (webhook_secret_key, header_key, ...) at the top level of source config were dropped by the API, creating an unverified source with exit 0. Fail with the corrected JSON instead. The webhook-secret shorthand now sends only the secret for provider source types so the API applies their own header/algorithm/encoding defaults (e.g. VAPI x-signature); WEBHOOK/HTTP keep x-webhook-signature/sha256/hex. Co-Authored-By: Claude Opus 5.5 --- pkg/cmd/connection_create.go | 6 ++ pkg/cmd/source_common.go | 102 ++++++++++++++++++++++++++++------ pkg/cmd/source_common_test.go | 78 ++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 pkg/cmd/source_common_test.go diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index 3f731560..9819f6fc 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -796,6 +796,9 @@ func (cc *connectionCreateCmd) buildSourceConfig() (map[string]interface{}, erro return nil, fmt.Errorf("invalid JSON in --source-config: %w", err) } normalizeSourceConfigAuth(config, cc.sourceType) + if err := validateSourceConfigAuthShape(config); err != nil { + return nil, err + } ensureSourceConfigAuthTypeForHTTP(config, cc.sourceType) return config, nil } @@ -809,6 +812,9 @@ func (cc *connectionCreateCmd) buildSourceConfig() (map[string]interface{}, erro return nil, fmt.Errorf("invalid JSON in --source-config-file: %w", err) } normalizeSourceConfigAuth(config, cc.sourceType) + if err := validateSourceConfigAuthShape(config); err != nil { + return nil, err + } ensureSourceConfigAuthTypeForHTTP(config, cc.sourceType) return config, nil } diff --git a/pkg/cmd/source_common.go b/pkg/cmd/source_common.go index 443c7d03..9c37e5b5 100644 --- a/pkg/cmd/source_common.go +++ b/pkg/cmd/source_common.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "os" @@ -55,16 +56,7 @@ func buildSourceConfigFromIndividualFlags(f *sourceConfigFlags, flagPrefix, sour // Auth: only config.auth; shape depends on source type (API infers from type + auth keys). if f.WebhookSecret != "" { - if sourceTypeUpper == "STRIPE" { - config["auth"] = map[string]interface{}{"webhook_secret_key": f.WebhookSecret} - } else { - config["auth"] = map[string]interface{}{ - "algorithm": "sha256", - "encoding": "hex", - "header_key": "x-webhook-signature", - "webhook_secret_key": f.WebhookSecret, - } - } + config["auth"] = webhookSecretAuth(sourceTypeUpper, f.WebhookSecret) } else if f.HMACSecret != "" { algo := "sha256" if f.HMACAlgo != "" { @@ -124,6 +116,81 @@ func buildSourceConfigFromIndividualFlags(f *sourceConfigFlags, flagPrefix, sour return config, nil } +// genericHMACSourceTypes are the source types with no provider defaults on the API side: +// the caller must supply header_key, algorithm and encoding for HMAC verification. +// Every other type (STRIPE, VAPI, ...) has defaults for them, which the API applies to unset fields. +var genericHMACSourceTypes = map[string]bool{"": true, "WEBHOOK": true, "HTTP": true} + +// webhookSecretAuth builds config.auth for the webhook-secret shorthand. For provider source +// types it sends only the secret so the API applies that type's own header, algorithm and +// encoding defaults (for example VAPI uses x-signature). Explicit values would override those +// defaults and break verification of real provider requests. The generic WEBHOOK and HTTP +// types have no such defaults, so they get the conventional x-webhook-signature / sha256 / hex. +// sourceTypeUpper must already be upper-cased and trimmed. +func webhookSecretAuth(sourceTypeUpper, secret string) map[string]interface{} { + if !genericHMACSourceTypes[sourceTypeUpper] { + return map[string]interface{}{"webhook_secret_key": secret} + } + return map[string]interface{}{ + "algorithm": "sha256", + "encoding": "hex", + "header_key": "x-webhook-signature", + "webhook_secret_key": secret, + } +} + +// authFieldKeys are auth settings that belong under config.auth. At the top level of source +// config the API silently drops them (it strips unknown keys), leaving the source unverified. +var authFieldKeys = []string{ + "webhook_secret_key", "algorithm", "encoding", "header_key", "trim_payload", + "api_key", "username", "password", +} + +// validateSourceConfigAuthShape returns an error when auth fields sit at the top level of the +// source config instead of under "auth". It runs after normalizeSourceConfigAuth, which already +// moves the legacy shorthand keys, so what remains here would otherwise be dropped by the API. +// The error shows the corrected JSON. +func validateSourceConfigAuthShape(config map[string]interface{}) error { + var misplaced []string + for _, k := range authFieldKeys { + if _, ok := config[k]; ok { + misplaced = append(misplaced, k) + } + } + if len(misplaced) == 0 { + return nil + } + auth := map[string]interface{}{} + if existing, ok := config["auth"].(map[string]interface{}); ok { + for k, v := range existing { + auth[k] = v + } + } + fixed := map[string]interface{}{} + for k, v := range config { + fixed[k] = v + } + for _, k := range misplaced { + auth[k] = fixed[k] + delete(fixed, k) + } + fixed["auth"] = auth + // Do not echo secrets back in the suggestion. + for _, k := range []string{"webhook_secret_key", "api_key", "password"} { + if _, ok := auth[k]; ok { + auth[k] = "<" + k + ">" + } + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) // keep the text readable + enc.SetIndent("", " ") + _ = enc.Encode(fixed) + suggestion := strings.TrimRight(buf.String(), "\n") + return fmt.Errorf("source config has auth settings at the top level (%s); the API ignores them there, so the source would be created without verification.\nMove them under \"auth\":\n%s", + strings.Join(misplaced, ", "), suggestion) +} + // ensureSourceConfigAuthTypeForHTTP sets config.auth_type when source type is HTTP and config // has auth. The connection API requires auth_type in config for HTTP sources. Values: API_KEY, // BASIC_AUTH, HMAC. No-op if auth_type already set or source type is not HTTP. @@ -163,14 +230,7 @@ func normalizeSourceConfigAuth(config map[string]interface{}, sourceType string) } sourceTypeUpper := strings.ToUpper(strings.TrimSpace(sourceType)) if v, ok := config["webhook_secret"].(string); ok && v != "" { - if sourceTypeUpper == "STRIPE" { - config["auth"] = map[string]interface{}{"webhook_secret_key": v} - } else { - config["auth"] = map[string]interface{}{ - "algorithm": "sha256", "encoding": "hex", - "header_key": "x-webhook-signature", "webhook_secret_key": v, - } - } + config["auth"] = webhookSecretAuth(sourceTypeUpper, v) delete(config, "webhook_secret") return } @@ -216,6 +276,9 @@ func buildSourceConfigFromFlags(configStr, configFile string, individual *source return nil, fmt.Errorf("invalid JSON in --config: %w", err) } normalizeSourceConfigAuth(out, sourceType) + if err := validateSourceConfigAuthShape(out); err != nil { + return nil, err + } return out, nil } if configFile != "" { @@ -228,6 +291,9 @@ func buildSourceConfigFromFlags(configStr, configFile string, individual *source return nil, fmt.Errorf("invalid JSON in config file: %w", err) } normalizeSourceConfigAuth(out, sourceType) + if err := validateSourceConfigAuthShape(out); err != nil { + return nil, err + } return out, nil } return buildSourceConfigFromIndividualFlags(individual, "", sourceType) diff --git a/pkg/cmd/source_common_test.go b/pkg/cmd/source_common_test.go new file mode 100644 index 00000000..b8f49c23 --- /dev/null +++ b/pkg/cmd/source_common_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebhookSecretShorthandDefaults(t *testing.T) { + t.Run("provider types send only the secret so API defaults apply", func(t *testing.T) { + for _, typ := range []string{"VAPI", "vapi", "STRIPE", "GITHUB"} { + cfg, err := buildSourceConfigFromIndividualFlags(&sourceConfigFlags{WebhookSecret: "s3cret"}, "", typ) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"webhook_secret_key": "s3cret"}, cfg["auth"], typ) + } + }) + t.Run("generic types keep conventional HMAC defaults", func(t *testing.T) { + for _, typ := range []string{"WEBHOOK", "HTTP", ""} { + cfg, err := buildSourceConfigFromIndividualFlags(&sourceConfigFlags{WebhookSecret: "s3cret"}, "", typ) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "algorithm": "sha256", "encoding": "hex", + "header_key": "x-webhook-signature", "webhook_secret_key": "s3cret", + }, cfg["auth"], typ) + } + }) + t.Run("legacy flat webhook_secret in JSON follows the same rule", func(t *testing.T) { + cfg, err := buildSourceConfigFromFlags(`{"webhook_secret":"s3cret"}`, "", nil, "VAPI") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"webhook_secret_key": "s3cret"}, cfg["auth"]) + assert.NotContains(t, cfg, "webhook_secret") + }) +} + +func TestValidateSourceConfigAuthShape(t *testing.T) { + t.Run("flat VAPI auth keys are rejected with corrected JSON, secret redacted", func(t *testing.T) { + _, err := buildSourceConfigFromFlags( + `{"webhook_secret_key":"topsecret","algorithm":"sha256","encoding":"hex","header_key":"x-signature","trim_payload":false}`, + "", nil, "VAPI") + require.Error(t, err) + msg := err.Error() + for _, k := range []string{"webhook_secret_key", "algorithm", "encoding", "header_key", "trim_payload"} { + assert.Contains(t, msg, k) + } + assert.Contains(t, msg, `"auth": {`) + assert.Contains(t, msg, "") + assert.NotContains(t, msg, "topsecret") + }) + t.Run("correct shape passes", func(t *testing.T) { + cfg, err := buildSourceConfigFromFlags(`{"auth":{"webhook_secret_key":"s","header_key":"x-signature"}}`, "", nil, "VAPI") + require.NoError(t, err) + assert.Contains(t, cfg, "auth") + }) + t.Run("misplaced keys alongside an existing auth object are merged in the suggestion", func(t *testing.T) { + err := validateSourceConfigAuthShape(map[string]interface{}{ + "auth": map[string]interface{}{"webhook_secret_key": "s"}, + "algorithm": "sha1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithm") + assert.True(t, strings.Contains(err.Error(), `"auth"`)) + }) + t.Run("non-auth keys are untouched", func(t *testing.T) { + assert.NoError(t, validateSourceConfigAuthShape(map[string]interface{}{ + "allowed_http_methods": []interface{}{"POST"}, + })) + }) + t.Run("connection --source-config path rejects flat keys too", func(t *testing.T) { + cc := &connectionCreateCmd{} + cc.sourceType = "VAPI" + cc.SourceConfig = `{"webhook_secret_key":"s","header_key":"x-signature"}` + _, err := cc.buildSourceConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "header_key") + }) +}