Skip to content
Draft
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
6 changes: 6 additions & 0 deletions pkg/cmd/connection_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
102 changes: 84 additions & 18 deletions pkg/cmd/source_common.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 <placeholder> 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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions pkg/cmd/source_common_test.go
Original file line number Diff line number Diff line change
@@ -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, "<webhook_secret_key>")
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")
})
}
Loading