diff --git a/docs/pages/references/configuration.mdx b/docs/pages/references/configuration.mdx index f9d98cee..f5d814df 100644 --- a/docs/pages/references/configuration.mdx +++ b/docs/pages/references/configuration.mdx @@ -47,16 +47,17 @@ Global configurations are provided through env variables or a YAML file. ConfigM | `DESTINATIONS_AWS_KINESIS_METADATA_IN_PAYLOAD` | If true, includes Outpost metadata (event ID, topic, etc.) within the Kinesis record payload. | `true` | No | | `DESTINATIONS_INCLUDE_MILLISECOND_TIMESTAMP` | If true, includes a 'timestamp-ms' field with millisecond precision in destination metadata. Useful for load testing and debugging. | `false` | No | | `DESTINATIONS_METADATA_PATH` | Path to the directory containing custom destination type definitions. This can be overridden by the root-level 'destination_metadata_path' if also set. | `config/outpost/destinations` | No | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. | `false` | No | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. | `false` | No | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. | `false` | No | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. | `false` | No | -| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). | `x-outpost-` | No | +| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode. | `false` | No | +| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode. | `false` | No | +| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode. | `false` | No | +| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode. | `false` | No | +| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode. | `x-outpost-` | No | +| `DESTINATIONS_WEBHOOK_MODE` | Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'. | `nil` | No | | `DESTINATIONS_WEBHOOK_PROXY_URL` | Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy. | `nil` | No | -| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). | `hmac-sha256` | No | -| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. | `{{.Timestamp.Unix}}.{{.Body}}` | No | -| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). | `hex` | No | -| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | Go template for the value of the signature header. | `t={{.Timestamp.Unix}},v0={{.Signatures \| join ","}}` | No | +| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode. | `hmac-sha256` | No | +| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode. | `{{.Timestamp.Unix}}.{{.Body}}` | No | +| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode. | `hex` | No | +| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | Go template for the value of the signature header. Only applies to 'default' mode. | `t={{.Timestamp.Unix}},v0={{.Signatures \| join ","}}` | No | | `DESTINATION_METADATA_PATH` | Path to the directory containing custom destination type definitions. Overrides 'destinations.metadata_path' if set. | `nil` | No | | `DISABLE_TELEMETRY` | Global flag to disable all telemetry (anonymous usage statistics to Hookdeck and error reporting to Sentry). If true, overrides 'telemetry.disabled'. | `false` | No | | `GCP_PUBSUB_DELIVERY_SUBSCRIPTION` | Name of the GCP Pub/Sub subscription for delivery events. | `outpost-delivery-sub` | No | @@ -186,34 +187,37 @@ destinations: # Configuration specific to webhook destinations. webhook: - # If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. + # If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode. disable_default_event_id_header: false - # If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. + # If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode. disable_default_signature_header: false - # If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. + # If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode. disable_default_timestamp_header: false - # If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. + # If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode. disable_default_topic_header: false - # Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-'). + # Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode. header_prefix: "x-outpost-" + # Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'. + mode: "" + # Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy. proxy_url: "" - # Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). + # Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode. signature_algorithm: "hmac-sha256" - # Go template for constructing the content to be signed for webhook requests. + # Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode. signature_content_template: "{{.Timestamp.Unix}}.{{.Body}}" - # Encoding for the signature (e.g., 'hex', 'base64'). + # Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode. signature_encoding: "hex" - # Go template for the value of the signature header. + # Go template for the value of the signature header. Only applies to 'default' mode. signature_header_template: "t={{.Timestamp.Unix}},v0={{.Signatures | join \",\"}}" diff --git a/go.mod b/go.mod index 8dfa8a79..c370e720 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/redis/go-redis/v9 v9.6.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/viper v1.19.0 + github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.36.0 github.com/testcontainers/testcontainers-go/modules/clickhouse v0.33.0 diff --git a/go.sum b/go.sum index 57376c11..bdbe3f79 100644 --- a/go.sum +++ b/go.sum @@ -1249,6 +1249,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c h1:Mm99t6GdFMtZOwyyvu3q8gXeZX0sqnjvimTC9QCJwQc= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/internal/config/destinations.go b/internal/config/destinations.go index 13f00c39..1d9aa926 100644 --- a/internal/config/destinations.go +++ b/internal/config/destinations.go @@ -38,21 +38,27 @@ type DestinationWebhookConfig struct { // ProxyURL may contain authentication credentials (e.g., http://user:pass@proxy:8080) // and should be treated as sensitive. // TODO: Implement sensitive value handling - https://github.com/hookdeck/outpost/issues/480 + Mode string `yaml:"mode" env:"DESTINATIONS_WEBHOOK_MODE" desc:"Webhook mode: 'default' for customizable webhooks or 'standard' for Standard Webhooks specification compliance. Defaults to 'default'." required:"N"` ProxyURL string `yaml:"proxy_url" env:"DESTINATIONS_WEBHOOK_PROXY_URL" desc:"Proxy URL for routing webhook requests through a proxy server. Supports HTTP and HTTPS proxies. When configured, all outgoing webhook traffic will be routed through the specified proxy." required:"N"` - HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for custom headers added to webhook requests (e.g., 'X-MyOrg-')." required:"N"` - DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests." required:"N"` - DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"If true, disables adding the default 'X-Outpost-Signature' header to webhook requests." required:"N"` - DisableDefaultTimestampHeader bool `yaml:"disable_default_timestamp_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER" desc:"If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests." required:"N"` - DisableDefaultTopicHeader bool `yaml:"disable_default_topic_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER" desc:"If true, disables adding the default 'X-Outpost-Topic' header to webhook requests." required:"N"` - SignatureContentTemplate string `yaml:"signature_content_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE" desc:"Go template for constructing the content to be signed for webhook requests." required:"N"` - SignatureHeaderTemplate string `yaml:"signature_header_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE" desc:"Go template for the value of the signature header." required:"N"` - SignatureEncoding string `yaml:"signature_encoding" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING" desc:"Encoding for the signature (e.g., 'hex', 'base64')." required:"N"` - SignatureAlgorithm string `yaml:"signature_algorithm" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM" desc:"Algorithm used for signing webhook requests (e.g., 'hmac-sha256')." required:"N"` + HeaderPrefix string `yaml:"header_prefix" env:"DESTINATIONS_WEBHOOK_HEADER_PREFIX" desc:"Prefix for metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' mode." required:"N"` + DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"If true, disables adding the default 'X-Outpost-Event-Id' header to webhook requests. Only applies to 'default' mode." required:"N"` + DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"If true, disables adding the default 'X-Outpost-Signature' header to webhook requests. Only applies to 'default' mode." required:"N"` + DisableDefaultTimestampHeader bool `yaml:"disable_default_timestamp_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER" desc:"If true, disables adding the default 'X-Outpost-Timestamp' header to webhook requests. Only applies to 'default' mode." required:"N"` + DisableDefaultTopicHeader bool `yaml:"disable_default_topic_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER" desc:"If true, disables adding the default 'X-Outpost-Topic' header to webhook requests. Only applies to 'default' mode." required:"N"` + SignatureContentTemplate string `yaml:"signature_content_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE" desc:"Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode." required:"N"` + SignatureHeaderTemplate string `yaml:"signature_header_template" env:"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE" desc:"Go template for the value of the signature header. Only applies to 'default' mode." required:"N"` + SignatureEncoding string `yaml:"signature_encoding" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING" desc:"Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode." required:"N"` + SignatureAlgorithm string `yaml:"signature_algorithm" env:"DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM" desc:"Algorithm used for signing webhook requests (e.g., 'hmac-sha256'). Only applies to 'default' mode." required:"N"` } // toConfig converts WebhookConfig to the provider config - private since it's only used internally func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookConfig { + mode := c.Mode + if mode == "" { + mode = "default" + } return &destregistrydefault.DestWebhookConfig{ + Mode: mode, ProxyURL: c.ProxyURL, HeaderPrefix: c.HeaderPrefix, DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader, diff --git a/internal/destregistry/metadata/providers/webhook_standard/instructions.md b/internal/destregistry/metadata/providers/webhook_standard/instructions.md new file mode 100644 index 00000000..dfc3f577 --- /dev/null +++ b/internal/destregistry/metadata/providers/webhook_standard/instructions.md @@ -0,0 +1,16 @@ +# Webhook Configuration Instructions + +To receive events from the webhook destination, you need to set up a webhook endpoint. + +A webhook endpoint is a URL that you provide to an HTTP server. When an event is sent to the webhook destination, an HTTP POST request is made to the webhook endpoint with a JSON payload. Information such as the event type will be sent in the HTTP headers. + +## Verifying Webhook Signatures + +Webhooks include a cryptographic signature for security. To verify: + +1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers +2. Construct the signed content: `${webhook-id}.${webhook-timestamp}.${raw_body}` +3. Decode your `whsec_` secret (remove prefix and base64 decode) +4. Compute HMAC-SHA256 signature and compare + +Verification libraries are available at: https://github.com/standard-webhooks diff --git a/internal/destregistry/metadata/providers/webhook_standard/metadata.json b/internal/destregistry/metadata/providers/webhook_standard/metadata.json new file mode 100644 index 00000000..377ac49c --- /dev/null +++ b/internal/destregistry/metadata/providers/webhook_standard/metadata.json @@ -0,0 +1,18 @@ +{ + "type": "webhook", + "config_fields": [ + { + "key": "url", + "type": "text", + "label": "Webhook URL", + "description": "The URL to send webhook events to via HTTP POST (Standard Webhooks compliant)", + "required": true, + "pattern": "^https?:\\/\\/[\\w\\-]+(?:\\.[\\w\\-]+)*(?::\\d{1,5})?(?:\\/[\\w\\-\\/\\.~:?#\\[\\]@!$&'\\(\\)*+,;=]*)?$" + } + ], + "credential_fields": [], + "label": "Webhook", + "link": "https://hookdeck.com/webhooks/guides/what-are-webhooks-how-they-work", + "description": "Send events as webhooks (HTTP POST).", + "icon": "" +} diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go index 433a5e90..4622b97f 100644 --- a/internal/destregistry/providers/default.go +++ b/internal/destregistry/providers/default.go @@ -9,9 +9,11 @@ import ( "github.com/hookdeck/outpost/internal/destregistry/providers/desthookdeck" "github.com/hookdeck/outpost/internal/destregistry/providers/destrabbitmq" "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook" + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard" ) type DestWebhookConfig struct { + Mode string ProxyURL string HeaderPrefix string DisableDefaultEventIDHeader bool @@ -47,28 +49,44 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina basePublisherOpts = append(basePublisherOpts, destregistry.WithMillisecondTimestamp(opts.IncludeMillisecondTimestamp)) } - webhookOpts := []destwebhook.Option{ - destwebhook.WithUserAgent(opts.UserAgent), + // Register webhook provider based on mode + if opts.Webhook != nil && opts.Webhook.Mode == "standard" { + // Standard Webhooks mode - register webhook_standard as "webhook" + webhookStandardOpts := []destwebhookstandard.Option{ + destwebhookstandard.WithUserAgent(opts.UserAgent), + destwebhookstandard.WithProxyURL(opts.Webhook.ProxyURL), + destwebhookstandard.WithHeaderPrefix(opts.Webhook.HeaderPrefix), + } + webhookStandard, err := destwebhookstandard.New(loader, basePublisherOpts, webhookStandardOpts...) + if err != nil { + return err + } + registry.RegisterProvider("webhook", webhookStandard) + } else { + // Default mode - register customizable webhook as "webhook" + webhookOpts := []destwebhook.Option{ + destwebhook.WithUserAgent(opts.UserAgent), + } + if opts.Webhook != nil { + webhookOpts = append(webhookOpts, + destwebhook.WithProxyURL(opts.Webhook.ProxyURL), + destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix), + destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader), + destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader), + destwebhook.WithDisableDefaultTimestampHeader(opts.Webhook.DisableDefaultTimestampHeader), + destwebhook.WithDisableDefaultTopicHeader(opts.Webhook.DisableDefaultTopicHeader), + destwebhook.WithSignatureContentTemplate(opts.Webhook.SignatureContentTemplate), + destwebhook.WithSignatureHeaderTemplate(opts.Webhook.SignatureHeaderTemplate), + destwebhook.WithSignatureEncoding(opts.Webhook.SignatureEncoding), + destwebhook.WithSignatureAlgorithm(opts.Webhook.SignatureAlgorithm), + ) + } + webhook, err := destwebhook.New(loader, basePublisherOpts, webhookOpts...) + if err != nil { + return err + } + registry.RegisterProvider("webhook", webhook) } - if opts.Webhook != nil { - webhookOpts = append(webhookOpts, - destwebhook.WithProxyURL(opts.Webhook.ProxyURL), - destwebhook.WithHeaderPrefix(opts.Webhook.HeaderPrefix), - destwebhook.WithDisableDefaultEventIDHeader(opts.Webhook.DisableDefaultEventIDHeader), - destwebhook.WithDisableDefaultSignatureHeader(opts.Webhook.DisableDefaultSignatureHeader), - destwebhook.WithDisableDefaultTimestampHeader(opts.Webhook.DisableDefaultTimestampHeader), - destwebhook.WithDisableDefaultTopicHeader(opts.Webhook.DisableDefaultTopicHeader), - destwebhook.WithSignatureContentTemplate(opts.Webhook.SignatureContentTemplate), - destwebhook.WithSignatureHeaderTemplate(opts.Webhook.SignatureHeaderTemplate), - destwebhook.WithSignatureEncoding(opts.Webhook.SignatureEncoding), - destwebhook.WithSignatureAlgorithm(opts.Webhook.SignatureAlgorithm), - ) - } - webhook, err := destwebhook.New(loader, basePublisherOpts, webhookOpts...) - if err != nil { - return err - } - registry.RegisterProvider("webhook", webhook) hookdeck, err := desthookdeck.New(loader, basePublisherOpts, desthookdeck.WithUserAgent(opts.UserAgent)) diff --git a/internal/destregistry/providers/destwebhook/destwebhook.go b/internal/destregistry/providers/destwebhook/destwebhook.go index 39493ee9..f7a120fa 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook.go +++ b/internal/destregistry/providers/destwebhook/destwebhook.go @@ -534,18 +534,33 @@ func (p *WebhookPublisher) Publish(ctx context.Context, event *models.Event) (*d defer resp.Body.Close() if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) delivery := &destregistry.Delivery{ Status: "failed", Code: fmt.Sprintf("%d", resp.StatusCode), } parseResponse(delivery, resp) + + // Extract body from delivery.Response for error details + var bodyStr string + if delivery.Response != nil { + if body, ok := delivery.Response["body"]; ok { + switch v := body.(type) { + case string: + bodyStr = v + case map[string]interface{}: + if jsonBytes, err := json.Marshal(v); err == nil { + bodyStr = string(jsonBytes) + } + } + } + } + return delivery, destregistry.NewErrDestinationPublishAttempt( - fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)), + fmt.Errorf("request failed with status %d: %s", resp.StatusCode, bodyStr), "webhook", map[string]interface{}{ "status": resp.StatusCode, - "body": string(bodyBytes), + "body": bodyStr, }) } diff --git a/internal/destregistry/providers/destwebhookstandard/assert_test.go b/internal/destregistry/providers/destwebhookstandard/assert_test.go new file mode 100644 index 00000000..c82cbe20 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/assert_test.go @@ -0,0 +1,77 @@ +package destwebhookstandard_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strings" + + testsuite "github.com/hookdeck/outpost/internal/destregistry/testing" + standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertValidStandardWebhookSignature verifies a Standard Webhooks signature +// using both our manual verification AND the official Standard Webhooks SDK +func assertValidStandardWebhookSignature(t testsuite.TestingT, secret, msgID, timestamp string, body []byte, signatureHeader string) { + t.Helper() + + // First, verify using the official Standard Webhooks SDK + // This ensures our implementation is compatible with the official library + wh, err := standardwebhooks.NewWebhook(secret) + require.NoError(t, err, "failed to create webhook verifier with official SDK") + + headers := http.Header{} + headers.Set("webhook-id", msgID) + headers.Set("webhook-timestamp", timestamp) + headers.Set("webhook-signature", signatureHeader) + + err = wh.Verify(body, headers) + assert.NoError(t, err, "official Standard Webhooks SDK should verify our signature") + + // Also verify manually to ensure we understand the signature format + encodedPart := strings.TrimPrefix(secret, "whsec_") + decodedSecret, err := base64.StdEncoding.DecodeString(encodedPart) + require.NoError(t, err, "secret should decode successfully") + + // Construct signed content: msg_id.timestamp.body + signedContent := fmt.Sprintf("%s.%s.%s", msgID, timestamp, string(body)) + + // Generate expected signature + mac := hmac.New(sha256.New, decodedSecret) + mac.Write([]byte(signedContent)) + expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + // Check if any signature in header matches + signatures := strings.Split(signatureHeader, " ") + found := false + for _, sig := range signatures { + sigPart := strings.TrimPrefix(sig, "v1,") + if hmac.Equal([]byte(sigPart), []byte(expectedSig)) { + found = true + break + } + } + + assert.True(t, found, "no valid signature found in header (manual verification)") +} + +// assertSignatureFormat verifies the signature header format +func assertSignatureFormat(t testsuite.TestingT, signatureHeader string, expectedCount int) { + t.Helper() + + signatures := strings.Split(signatureHeader, " ") + assert.Equal(t, expectedCount, len(signatures), "signature count mismatch") + + for i, sig := range signatures { + assert.True(t, strings.HasPrefix(sig, "v1,"), "signature %d should have v1, prefix", i) + + // Verify it's valid base64 + sigPart := strings.TrimPrefix(sig, "v1,") + _, err := base64.StdEncoding.DecodeString(sigPart) + assert.NoError(t, err, "signature %d should be valid base64", i) + } +} diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go new file mode 100644 index 00000000..61bd5f96 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go @@ -0,0 +1,663 @@ +package destwebhookstandard + +/* +Standard Webhooks Destination Provider + +This implementation is based on the destwebhook provider and reuses its core +signature management infrastructure (SignatureManager). The key differences are: + +1. Secret Format: + - destwebhook: Flexible format (any string, typically hex-encoded) + - destwebhookstandard: Strict "whsec_" format per Standard Webhooks spec + +2. Header Names: + - destwebhook: Customizable via templates + - destwebhookstandard: Uses configurable prefix for all headers: "id", "timestamp", "signature", + and metadata headers (topic, etc.) + - Prefix defaults to "webhook-" in standard mode, "x-outpost-" in default mode + - Examples: "webhook-id", "webhook-timestamp" OR "x-custom-id", "x-custom-timestamp" + +3. Signature Format: + - destwebhook: Customizable template + - destwebhookstandard: Fixed "${webhook-id}.${timestamp}.${body}" signed content + and "v1," signature format + +ARCHITECTURE NOTES: + +- We import and use destwebhook.SignatureManager for signature generation +- We use destwebhook.WebhookSecret for secret storage +- Secret validation, parsing, and rotation logic is currently duplicated here + +FUTURE REFACTORING CONSIDERATIONS: + +If this pattern proves useful, consider extracting shared logic into a common package: +- Secret validation and parsing helpers +- Secret rotation logic (with configurable format validators) +- Common credential preprocessing patterns +- Shared validation error construction + +This would allow both destwebhook and destwebhookstandard to share the same +underlying infrastructure while maintaining their specific requirements. + +For now, we keep them separate to: +1. Avoid breaking changes to the existing destwebhook implementation +2. Allow independent evolution of the two providers +3. Keep the Standard Webhooks implementation self-contained for easier review + +Related files to consider for refactoring: +- internal/destregistry/providers/destwebhook/signature.go (SignatureManager) +- internal/destregistry/providers/destwebhook/destwebhook.go (rotation logic) +*/ + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/hookdeck/outpost/internal/destregistry" + "github.com/hookdeck/outpost/internal/destregistry/metadata" + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook" + "github.com/hookdeck/outpost/internal/models" +) + +type StandardWebhookDestination struct { + *destregistry.BaseProvider + userAgent string + proxyURL string + headerPrefix string // Prefix for metadata headers (defaults to "webhook-") +} + +type StandardWebhookDestinationConfig struct { + URL string `json:"url"` +} + +type StandardWebhookDestinationCredentials struct { + Secret string `json:"secret"` + PreviousSecret string `json:"previous_secret,omitempty"` + PreviousSecretInvalidAt *time.Time `json:"previous_secret_invalid_at,omitempty"` +} + +var _ destregistry.Provider = (*StandardWebhookDestination)(nil) + +// Option is a functional option for configuring StandardWebhookDestination +type Option func(*StandardWebhookDestination) + +// WithUserAgent sets the user agent for the webhook request +func WithUserAgent(userAgent string) Option { + return func(d *StandardWebhookDestination) { + d.userAgent = userAgent + } +} + +// WithProxyURL sets the proxy URL for the webhook request +func WithProxyURL(proxyURL string) Option { + return func(d *StandardWebhookDestination) { + if proxyURL != "" { + d.proxyURL = proxyURL + } + } +} + +// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-") +func WithHeaderPrefix(prefix string) Option { + return func(d *StandardWebhookDestination) { + if prefix != "" { + d.headerPrefix = prefix + } + } +} + +func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePublisherOption, opts ...Option) (*StandardWebhookDestination, error) { + base, err := destregistry.NewBaseProvider(loader, "webhook_standard", basePublisherOpts...) + if err != nil { + return nil, err + } + destination := &StandardWebhookDestination{ + BaseProvider: base, + headerPrefix: "webhook-", // Default to Standard Webhooks spec + } + for _, opt := range opts { + opt(destination) + } + return destination, nil +} + +func (d *StandardWebhookDestination) ComputeTarget(destination *models.Destination) destregistry.DestinationTarget { + return destregistry.DestinationTarget{ + Target: destination.Config["url"], + TargetURL: "", + } +} + +func (d *StandardWebhookDestination) ObfuscateDestination(destination *models.Destination) *models.Destination { + result := *destination // shallow copy + result.Config = make(map[string]string, len(destination.Config)) + result.Credentials = make(map[string]string, len(destination.Credentials)) + + // Copy config values + for key, value := range destination.Config { + result.Config[key] = value + } + + // Copy credentials as is + // NOTE: Secrets are intentionally not obfuscated for now because: + // 1. They're needed for secret rotation logic + // 2. They're less security-critical than other provider credentials + for key, value := range destination.Credentials { + result.Credentials[key] = value + } + + return &result +} + +func (d *StandardWebhookDestination) Validate(ctx context.Context, destination *models.Destination) error { + if _, _, err := d.resolveConfig(ctx, destination); err != nil { + return err + } + return nil +} + +func (d *StandardWebhookDestination) CreatePublisher(ctx context.Context, destination *models.Destination) (destregistry.Publisher, error) { + config, creds, err := d.resolveConfig(ctx, destination) + if err != nil { + return nil, err + } + + // Parse and validate secrets + now := time.Now() + var secrets []destwebhook.WebhookSecret + + // Parse current secret + parsedSecret, err := parseSecret(creds.Secret) + if err != nil { + return nil, fmt.Errorf("failed to parse secret: %w", err) + } + secrets = append(secrets, destwebhook.WebhookSecret{ + Key: parsedSecret, + CreatedAt: now, + }) + + // Parse previous secret if present + if creds.PreviousSecret != "" { + parsedPrevSecret, err := parseSecret(creds.PreviousSecret) + if err != nil { + return nil, fmt.Errorf("failed to parse previous_secret: %w", err) + } + secrets = append(secrets, destwebhook.WebhookSecret{ + Key: parsedPrevSecret, + CreatedAt: now.Add(-1 * time.Hour), // Set to 1 hour before current secret + InvalidAt: creds.PreviousSecretInvalidAt, + }) + } + + // Create SignatureManager with Standard Webhooks templates + sm := destwebhook.NewSignatureManager( + secrets, + destwebhook.WithSignatureFormatter( + destwebhook.NewSignatureFormatter("{{.EventID}}.{{.Timestamp.Unix}}.{{.Body}}"), + ), + destwebhook.WithHeaderFormatter( + destwebhook.NewHeaderFormatter("v1,{{index .Signatures 0}}{{range slice .Signatures 1}} v1,{{.}}{{end}}"), + ), + destwebhook.WithEncoder(destwebhook.GetEncoder("base64")), + destwebhook.WithAlgorithm(destwebhook.GetAlgorithm("hmac-sha256")), + ) + + var proxyURL *string + if d.proxyURL != "" { + proxyURL = &d.proxyURL + } + + httpClient, err := d.BaseProvider.MakeHTTPClient(destregistry.HTTPClientConfig{ + UserAgent: &d.userAgent, + ProxyURL: proxyURL, + }) + if err != nil { + return nil, err + } + + return &StandardWebhookPublisher{ + BasePublisher: d.BaseProvider.NewPublisher(), + httpClient: httpClient, + url: config.URL, + secrets: secrets, + sm: sm, + headerPrefix: d.headerPrefix, + }, nil +} + +func (d *StandardWebhookDestination) resolveConfig(ctx context.Context, destination *models.Destination) (*StandardWebhookDestinationConfig, *StandardWebhookDestinationCredentials, error) { + if err := d.BaseProvider.Validate(ctx, destination); err != nil { + return nil, nil, err + } + + config := &StandardWebhookDestinationConfig{ + URL: destination.Config["url"], + } + + // Parse credentials + creds := &StandardWebhookDestinationCredentials{ + Secret: destination.Credentials["secret"], + PreviousSecret: destination.Credentials["previous_secret"], + } + + // Skip validation if no relevant credentials are passed + if destination.Credentials["secret"] == "" && + destination.Credentials["previous_secret"] == "" && + destination.Credentials["previous_secret_invalid_at"] == "" { + return config, creds, nil + } + + // If any credentials are passed, secret is required + if creds.Secret == "" { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.secret", + Type: "required", + }}) + } + + // Validate secret format + if err := validateSecret(creds.Secret); err != nil { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.secret", + Type: "pattern", + }}) + } + + // Parse previous_secret_invalid_at if present + if invalidAtStr := destination.Credentials["previous_secret_invalid_at"]; invalidAtStr != "" { + invalidAt, err := time.Parse(time.RFC3339, invalidAtStr) + if err != nil { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.previous_secret_invalid_at", + Type: "pattern", + }}) + } + creds.PreviousSecretInvalidAt = &invalidAt + } + + // Validate previous_secret if provided + if creds.PreviousSecret != "" { + if err := validateSecret(creds.PreviousSecret); err != nil { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.previous_secret", + Type: "pattern", + }}) + } + + // Require invalidation time if previous secret is provided + if creds.PreviousSecretInvalidAt == nil { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.previous_secret_invalid_at", + Type: "required", + }}) + } + } + + // If previous_secret_invalid_at is provided, validate previous_secret + if creds.PreviousSecretInvalidAt != nil && creds.PreviousSecret == "" { + return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ + Field: "credentials.previous_secret", + Type: "required", + }}) + } + + return config, creds, nil +} + +// rotateSecret handles secret rotation and returns clean credentials +func (d *StandardWebhookDestination) rotateSecret(newDest, origDest *models.Destination) (map[string]string, error) { + if origDest == nil { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.rotate_secret", + Type: "invalid", + }, + }) + } + + if origDest.Credentials["secret"] == "" { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.secret", + Type: "required", + }, + }) + } + + creds := make(map[string]string) + + // Store the current secret as the previous secret + creds["previous_secret"] = origDest.Credentials["secret"] + + // Generate a new secret + secret, err := generateStandardSecret() + if err != nil { + return nil, err + } + creds["secret"] = secret + + // Keep custom invalidation time if provided, otherwise set default + if newDest.Credentials["previous_secret_invalid_at"] != "" { + creds["previous_secret_invalid_at"] = newDest.Credentials["previous_secret_invalid_at"] + } else { + creds["previous_secret_invalid_at"] = time.Now().Add(24 * time.Hour).Format(time.RFC3339) + } + + return creds, nil +} + +// updateSecret handles non-rotation updates and returns clean credentials +func (d *StandardWebhookDestination) updateSecret(newDest, origDest *models.Destination, opts *destregistry.PreprocessDestinationOpts) (map[string]string, error) { + creds := make(map[string]string) + + if opts.Role != "admin" { + // For tenants, first check if they're trying to modify any credential fields + if origDest != nil && origDest.Credentials != nil { + // Updating existing destination - must match original values + if newDest.Credentials["secret"] != "" && newDest.Credentials["secret"] != origDest.Credentials["secret"] { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.secret", + Type: "forbidden", + }, + }) + } + if newDest.Credentials["previous_secret"] != "" && newDest.Credentials["previous_secret"] != origDest.Credentials["previous_secret"] { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.previous_secret", + Type: "forbidden", + }, + }) + } + if newDest.Credentials["previous_secret_invalid_at"] != "" && newDest.Credentials["previous_secret_invalid_at"] != origDest.Credentials["previous_secret_invalid_at"] { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.previous_secret_invalid_at", + Type: "forbidden", + }, + }) + } + // Copy original values + for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} { + if value := origDest.Credentials[key]; value != "" { + creds[key] = value + } + } + } else { + // First time creation - can't set any credentials + if newDest.Credentials["secret"] != "" { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.secret", + Type: "forbidden", + }, + }) + } + if newDest.Credentials["previous_secret"] != "" { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.previous_secret", + Type: "forbidden", + }, + }) + } + if newDest.Credentials["previous_secret_invalid_at"] != "" { + return nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{ + { + Field: "credentials.previous_secret_invalid_at", + Type: "forbidden", + }, + }) + } + } + } else { + // Admin can set any values + for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} { + if value := newDest.Credentials[key]; value != "" { + creds[key] = value + } + } + } + + return creds, nil +} + +// ensureInitializedCredentials ensures credentials are initialized for new destinations +func (d *StandardWebhookDestination) ensureInitializedCredentials(creds map[string]string) (map[string]string, error) { + // If there are any credentials already, return them as is + if creds["secret"] != "" || creds["previous_secret"] != "" || creds["previous_secret_invalid_at"] != "" { + return creds, nil + } + + // Otherwise generate a new secret + secret, err := generateStandardSecret() + if err != nil { + return nil, err + } + return map[string]string{ + "secret": secret, + }, nil +} + +// validateAndSanitizeCredentials performs final validation and cleanup +func (d *StandardWebhookDestination) validateAndSanitizeCredentials(creds map[string]string) (map[string]string, error) { + // Set default previous_secret_invalid_at if previous_secret is set but invalid_at is not + if creds["previous_secret"] != "" && creds["previous_secret_invalid_at"] == "" { + creds["previous_secret_invalid_at"] = time.Now().Add(24 * time.Hour).Format(time.RFC3339) + } + + // Clean up any extra fields + cleanCreds := make(map[string]string) + for _, key := range []string{"secret", "previous_secret", "previous_secret_invalid_at"} { + if value := creds[key]; value != "" { + cleanCreds[key] = value + } + } + + return cleanCreds, nil +} + +// Preprocess sets a default secret if one isn't provided and handles secret rotation +func (d *StandardWebhookDestination) Preprocess(newDestination *models.Destination, originalDestination *models.Destination, opts *destregistry.PreprocessDestinationOpts) error { + // Initialize credentials if nil + if newDestination.Credentials == nil { + newDestination.Credentials = make(map[string]string) + } + + // Get clean credentials based on operation type + var cleanCredentials map[string]string + var err error + if isTruthy(newDestination.Credentials["rotate_secret"]) { + cleanCredentials, err = d.rotateSecret(newDestination, originalDestination) + } else { + cleanCredentials, err = d.updateSecret(newDestination, originalDestination, opts) + // For new destinations, ensure credentials are initialized if needed + if err == nil && originalDestination == nil { + cleanCredentials, err = d.ensureInitializedCredentials(cleanCredentials) + } + } + if err != nil { + return err + } + + // Final validation and sanitization + cleanCredentials, err = d.validateAndSanitizeCredentials(cleanCredentials) + if err != nil { + return err + } + + newDestination.Credentials = cleanCredentials + return nil +} + +type StandardWebhookPublisher struct { + *destregistry.BasePublisher + httpClient *http.Client + url string + secrets []destwebhook.WebhookSecret + sm *destwebhook.SignatureManager + headerPrefix string +} + +func (p *StandardWebhookPublisher) Close() error { + p.BasePublisher.StartClose() + return nil +} + +func (p *StandardWebhookPublisher) Publish(ctx context.Context, event *models.Event) (*destregistry.Delivery, error) { + if err := p.BasePublisher.StartPublish(); err != nil { + return nil, err + } + defer p.BasePublisher.FinishPublish() + + httpReq, err := p.Format(ctx, event) + if err != nil { + return nil, err + } + + resp, err := p.httpClient.Do(httpReq) + if err != nil { + return nil, destregistry.NewErrDestinationPublishAttempt(err, "webhook_standard", map[string]interface{}{ + "error": "request_failed", + "message": err.Error(), + }) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + delivery := &destregistry.Delivery{ + Status: "failed", + Code: fmt.Sprintf("%d", resp.StatusCode), + } + parseResponse(delivery, resp) + + // Extract body from delivery.Response for error details + var bodyStr string + if delivery.Response != nil { + if body, ok := delivery.Response["body"]; ok { + switch v := body.(type) { + case string: + bodyStr = v + case map[string]interface{}: + if jsonBytes, err := json.Marshal(v); err == nil { + bodyStr = string(jsonBytes) + } + } + } + } + + return delivery, destregistry.NewErrDestinationPublishAttempt( + fmt.Errorf("request failed with status %d: %s", resp.StatusCode, bodyStr), + "webhook_standard", + map[string]interface{}{ + "status": resp.StatusCode, + "body": bodyStr, + }) + } + + delivery := &destregistry.Delivery{ + Status: "success", + Code: fmt.Sprintf("%d", resp.StatusCode), + } + parseResponse(delivery, resp) + + return delivery, nil +} + +// Format creates an HTTP request formatted according to Standard Webhooks specification +func (p *StandardWebhookPublisher) Format(ctx context.Context, event *models.Event) (*http.Request, error) { + now := time.Now() + rawBody, err := json.Marshal(event.Data) + if err != nil { + return nil, fmt.Errorf("failed to marshal event data: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewBuffer(rawBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + // Use event ID directly as the message ID + // This ensures the same message ID is used across retry attempts + // TODO: Support configurable ID generator/template (e.g., "msg_" prefix) + messageID := event.ID + + // Set Standard Webhooks headers with configurable prefix + req.Header.Set(p.headerPrefix+"id", messageID) + req.Header.Set(p.headerPrefix+"timestamp", strconv.FormatInt(now.Unix(), 10)) + + // Generate and set signature header + signatureHeader := p.sm.GenerateSignatureHeader(destwebhook.SignaturePayload{ + EventID: messageID, + Topic: event.Topic, + Timestamp: now, + Body: string(rawBody), + }) + if signatureHeader != "" { + req.Header.Set(p.headerPrefix+"signature", signatureHeader) + } + + // Add event metadata as custom headers + // Get merged metadata (system + event metadata) using BasePublisher + metadata := p.BasePublisher.MakeMetadata(event, now) + for key, value := range metadata { + // Skip system metadata that's already handled by Standard Webhooks headers + // (webhook-id replaces event-id, webhook-timestamp replaces timestamp) + if key == "event-id" || key == "timestamp" { + continue + } + // Add with configured prefix (defaults to "webhook-") + req.Header.Set(p.headerPrefix+key, value) + } + + // Also add custom event metadata without prefix (user-defined metadata) + for key, value := range event.Metadata { + req.Header.Set(key, value) + } + + return req, nil +} + +// isTruthy checks if a string value represents a truthy value +func isTruthy(value string) bool { + switch strings.ToLower(value) { + case "true", "1", "on", "yes": + return true + default: + return false + } +} + +func parseResponse(delivery *destregistry.Delivery, resp *http.Response) { + if strings.Contains(resp.Header.Get("Content-Type"), "application/json") { + bodyBytes, _ := io.ReadAll(resp.Body) + var response map[string]interface{} + if err := json.Unmarshal(bodyBytes, &response); err != nil { + delivery.Response = map[string]interface{}{ + "status": resp.StatusCode, + "body": string(bodyBytes), + } + return + } + delivery.Response = map[string]interface{}{ + "status": resp.StatusCode, + "body": response, + } + } else { + bodyBytes, _ := io.ReadAll(resp.Body) + delivery.Response = map[string]interface{}{ + "status": resp.StatusCode, + "body": string(bodyBytes), + } + } +} diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go new file mode 100644 index 00000000..dc9005ad --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go @@ -0,0 +1,67 @@ +package destwebhookstandard_test + +import ( + "testing" + + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard" + "github.com/hookdeck/outpost/internal/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("creates provider with defaults", func(t *testing.T) { + t.Parallel() + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + assert.NotNil(t, provider) + }) + + t.Run("creates provider with user agent option", func(t *testing.T) { + t.Parallel() + provider, err := destwebhookstandard.New( + testutil.Registry.MetadataLoader(), + nil, + destwebhookstandard.WithUserAgent("test-agent"), + ) + require.NoError(t, err) + assert.NotNil(t, provider) + }) + + t.Run("creates provider with proxy URL option", func(t *testing.T) { + t.Parallel() + provider, err := destwebhookstandard.New( + testutil.Registry.MetadataLoader(), + nil, + destwebhookstandard.WithProxyURL("http://proxy.example.com"), + ) + require.NoError(t, err) + assert.NotNil(t, provider) + }) + + t.Run("creates provider with header prefix option", func(t *testing.T) { + t.Parallel() + provider, err := destwebhookstandard.New( + testutil.Registry.MetadataLoader(), + nil, + destwebhookstandard.WithHeaderPrefix("x-custom-"), + ) + require.NoError(t, err) + assert.NotNil(t, provider) + }) + + t.Run("creates provider with multiple options", func(t *testing.T) { + t.Parallel() + provider, err := destwebhookstandard.New( + testutil.Registry.MetadataLoader(), + nil, + destwebhookstandard.WithUserAgent("test-agent"), + destwebhookstandard.WithProxyURL("http://proxy.example.com"), + destwebhookstandard.WithHeaderPrefix("x-outpost-"), + ) + require.NoError(t, err) + assert.NotNil(t, provider) + }) +} diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go new file mode 100644 index 00000000..6d3539d3 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go @@ -0,0 +1,417 @@ +package destwebhookstandard_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard" + testsuite "github.com/hookdeck/outpost/internal/destregistry/testing" + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// StandardWebhookConsumer implements testsuite.MessageConsumer +type StandardWebhookConsumer struct { + server *httptest.Server + messages chan testsuite.Message + wg sync.WaitGroup +} + +func NewStandardWebhookConsumer() *StandardWebhookConsumer { + consumer := &StandardWebhookConsumer{ + messages: make(chan testsuite.Message, 100), + } + + consumer.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + consumer.wg.Add(1) + defer consumer.wg.Done() + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Extract all headers as metadata + metadata := make(map[string]string) + for k, v := range r.Header { + if len(v) > 0 { + // Convert header name to lowercase for consistent access + metadata[strings.ToLower(k)] = v[0] + } + } + + consumer.messages <- testsuite.Message{ + Data: body, + Metadata: metadata, + Raw: r, // Store raw request for detailed assertions + } + + w.WriteHeader(http.StatusOK) + })) + + return consumer +} + +func (c *StandardWebhookConsumer) Consume() <-chan testsuite.Message { + return c.messages +} + +func (c *StandardWebhookConsumer) Close() error { + c.wg.Wait() + c.server.Close() + close(c.messages) + return nil +} + +// StandardWebhookAsserter implements testsuite.MessageAsserter +type StandardWebhookAsserter struct { + secret string + expectedSignatures int + headerPrefix string // Defaults to "webhook-" +} + +func (a *StandardWebhookAsserter) AssertMessage(t testsuite.TestingT, msg testsuite.Message, event models.Event) { + req := msg.Raw.(*http.Request) + + // Verify HTTP properties + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + // Use configured prefix or default to "webhook-" + prefix := a.headerPrefix + if prefix == "" { + prefix = "webhook-" + } + + // Verify Standard Webhooks headers with configured prefix + webhookID := req.Header.Get(prefix + "id") + assert.NotEmpty(t, webhookID, prefix+"id should be present") + // Note: webhook-id format depends on event.ID format (user-provided) + + webhookTimestamp := req.Header.Get(prefix + "timestamp") + assert.NotEmpty(t, webhookTimestamp, prefix+"timestamp should be present") + testsuite.AssertTimestampIsUnixSeconds(t, webhookTimestamp) + + webhookSignature := req.Header.Get(prefix + "signature") + assert.NotEmpty(t, webhookSignature, prefix+"signature should be present") + + // Verify signature format and count + assertSignatureFormat(t, webhookSignature, a.expectedSignatures) + + // Verify signature with known secret (if provided) + if a.secret != "" { + assertValidStandardWebhookSignature(t, a.secret, webhookID, webhookTimestamp, msg.Data, webhookSignature) + } +} + +// StandardWebhookPublishSuite is the test suite +type StandardWebhookPublishSuite struct { + testsuite.PublisherSuite + consumer *StandardWebhookConsumer + setupFn func(*StandardWebhookPublishSuite) +} + +func (s *StandardWebhookPublishSuite) SetupSuite() { + s.setupFn(s) +} + +func (s *StandardWebhookPublishSuite) TearDownSuite() { + if s.consumer != nil { + s.consumer.Close() + } +} + +// Basic publish test configuration +func (s *StandardWebhookPublishSuite) setupBasicSuite() { + consumer := NewStandardWebhookConsumer() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(s.T(), err) + + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + s.InitSuite(testsuite.Config{ + Provider: provider, + Dest: &dest, + Consumer: consumer, + Asserter: &StandardWebhookAsserter{ + secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + expectedSignatures: 1, + }, + }) + + s.consumer = consumer +} + +// Multiple secrets test configuration +func (s *StandardWebhookPublishSuite) setupMultipleSecretsSuite() { + consumer := NewStandardWebhookConsumer() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(s.T(), err) + + now := time.Now() + invalidAt := now.Add(24 * time.Hour) + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_TmV3U2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==", + "previous_secret": "whsec_T2xkU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==", + "previous_secret_invalid_at": invalidAt.Format(time.RFC3339), + }), + ) + + s.InitSuite(testsuite.Config{ + Provider: provider, + Dest: &dest, + Consumer: consumer, + Asserter: &StandardWebhookAsserter{ + secret: "whsec_TmV3U2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==", + expectedSignatures: 2, + }, + }) + + s.consumer = consumer +} + +// Expired secrets test configuration +func (s *StandardWebhookPublishSuite) setupExpiredSecretsSuite() { + consumer := NewStandardWebhookConsumer() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(s.T(), err) + + now := time.Now() + invalidAt := now.Add(-1 * time.Hour) // Previous secret is already invalid + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_QWN0aXZlU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==", + "previous_secret": "whsec_RXhwaXJlZFNlY3JldEJhc2U2NEVuY29kZWRTdHJpbmcxMjM=", + "previous_secret_invalid_at": invalidAt.Format(time.RFC3339), + }), + ) + + s.InitSuite(testsuite.Config{ + Provider: provider, + Dest: &dest, + Consumer: consumer, + Asserter: &StandardWebhookAsserter{ + secret: "whsec_QWN0aXZlU2VjcmV0QmFzZTY0RW5jb2RlZFN0cmluZzEyMw==", + expectedSignatures: 1, // Only expect signature from active secret + }, + }) + + s.consumer = consumer +} + +func TestStandardWebhookPublish(t *testing.T) { + t.Parallel() + + // Run basic publish tests + t.Run("Basic", func(t *testing.T) { + t.Parallel() + suite.Run(t, &StandardWebhookPublishSuite{ + setupFn: (*StandardWebhookPublishSuite).setupBasicSuite, + }) + }) + + // Run multiple secrets tests + t.Run("MultipleSecrets", func(t *testing.T) { + t.Parallel() + suite.Run(t, &StandardWebhookPublishSuite{ + setupFn: (*StandardWebhookPublishSuite).setupMultipleSecretsSuite, + }) + }) + + // Run expired secrets tests + t.Run("ExpiredSecrets", func(t *testing.T) { + t.Parallel() + suite.Run(t, &StandardWebhookPublishSuite{ + setupFn: (*StandardWebhookPublishSuite).setupExpiredSecretsSuite, + }) + }) +} + +func TestStandardWebhookPublisher_SignatureFormat(t *testing.T) { + t.Parallel() + + consumer := NewStandardWebhookConsumer() + defer consumer.Close() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + publisher, err := provider.CreatePublisher(context.Background(), &dest) + require.NoError(t, err) + defer publisher.Close() + + event := testutil.EventFactory.Any( + testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"), + testutil.EventFactory.WithData(map[string]interface{}{"hello": "world"}), + ) + + _, err = publisher.Publish(context.Background(), &event) + require.NoError(t, err) + + // Get the message + select { + case msg := <-consumer.Consume(): + req := msg.Raw.(*http.Request) + + // Verify signature format is "v1," + signatureHeader := req.Header.Get("webhook-signature") + assert.True(t, strings.HasPrefix(signatureHeader, "v1,")) + + // Verify base64 + sigPart := strings.TrimPrefix(signatureHeader, "v1,") + decoded, err := base64.StdEncoding.DecodeString(sigPart) + assert.NoError(t, err) + assert.Equal(t, 32, len(decoded)) // HMAC-SHA256 produces 32 bytes + + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for message") + } +} + +func TestStandardWebhookPublisher_MessageIDFormat(t *testing.T) { + t.Parallel() + + consumer := NewStandardWebhookConsumer() + defer consumer.Close() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + publisher, err := provider.CreatePublisher(context.Background(), &dest) + require.NoError(t, err) + defer publisher.Close() + + event := testutil.EventFactory.Any( + testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"), + testutil.EventFactory.WithData(map[string]interface{}{"test": "data"}), + ) + + _, err = publisher.Publish(context.Background(), &event) + require.NoError(t, err) + + // Get the message + select { + case msg := <-consumer.Consume(): + req := msg.Raw.(*http.Request) + + // Verify webhook-id uses event ID directly and has msg_ prefix + webhookID := req.Header.Get("webhook-id") + assert.NotEmpty(t, webhookID) + assert.Equal(t, event.ID, webhookID) + assert.True(t, strings.HasPrefix(webhookID, "msg_"), "webhook-id should have msg_ prefix, got: %s", webhookID) + + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for message") + } +} + +func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) { + t.Parallel() + + consumer := NewStandardWebhookConsumer() + defer consumer.Close() + + // Create provider with custom header prefix + provider, err := destwebhookstandard.New( + testutil.Registry.MetadataLoader(), + nil, + destwebhookstandard.WithHeaderPrefix("x-custom-"), + ) + require.NoError(t, err) + + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": consumer.server.URL + "/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + publisher, err := provider.CreatePublisher(context.Background(), &dest) + require.NoError(t, err) + defer publisher.Close() + + event := testutil.EventFactory.Any( + testutil.EventFactory.WithID("msg_2KWPBgLlAfxdpx2AI54pPJ85f4W"), + testutil.EventFactory.WithData(map[string]interface{}{"test": "data"}), + testutil.EventFactory.WithTopic("user.created"), + ) + + _, err = publisher.Publish(context.Background(), &event) + require.NoError(t, err) + + // Get the message + select { + case msg := <-consumer.Consume(): + req := msg.Raw.(*http.Request) + + // Verify ALL headers use custom prefix (including Standard Webhooks headers) + assert.NotEmpty(t, req.Header.Get("x-custom-id"), "should have x-custom-id header") + assert.NotEmpty(t, req.Header.Get("x-custom-timestamp"), "should have x-custom-timestamp header") + assert.NotEmpty(t, req.Header.Get("x-custom-signature"), "should have x-custom-signature header") + assert.NotEmpty(t, req.Header.Get("x-custom-topic"), "should have x-custom-topic header") + assert.Equal(t, "user.created", req.Header.Get("x-custom-topic")) + + // Verify default prefix is NOT used + assert.Empty(t, req.Header.Get("webhook-id"), "should not have webhook-id header") + assert.Empty(t, req.Header.Get("webhook-timestamp"), "should not have webhook-timestamp header") + assert.Empty(t, req.Header.Get("webhook-signature"), "should not have webhook-signature header") + assert.Empty(t, req.Header.Get("webhook-topic"), "should not have webhook-topic header") + + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for message") + } +} diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go new file mode 100644 index 00000000..82e4b4fc --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go @@ -0,0 +1,460 @@ +package destwebhookstandard_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/hookdeck/outpost/internal/destregistry" + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard" + "github.com/hookdeck/outpost/internal/util/maputil" + "github.com/hookdeck/outpost/internal/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStandardWebhookDestination_Validate(t *testing.T) { + t.Parallel() + + validDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + + t.Run("should validate valid destination", func(t *testing.T) { + t.Parallel() + assert.NoError(t, provider.Validate(context.Background(), &validDestination)) + }) + + t.Run("should validate invalid type", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Type = "invalid" + err := provider.Validate(context.Background(), &invalidDestination) + assert.Error(t, err) + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "type", validationErr.Errors[0].Field) + assert.Equal(t, "invalid_type", validationErr.Errors[0].Type) + }) + + t.Run("should validate missing url", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Config = map[string]string{} + err := provider.Validate(context.Background(), &invalidDestination) + + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "config.url", validationErr.Errors[0].Field) + assert.Equal(t, "required", validationErr.Errors[0].Type) + }) + + t.Run("should validate malformed url", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Config = map[string]string{ + "url": "not-a-valid-url", + } + err := provider.Validate(context.Background(), &invalidDestination) + + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "config.url", validationErr.Errors[0].Field) + assert.Equal(t, "pattern", validationErr.Errors[0].Type) + }) + + t.Run("should validate secret without whsec prefix", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Credentials = map[string]string{ + "secret": "not-a-whsec-secret", + } + err := provider.Validate(context.Background(), &invalidDestination) + + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field) + assert.Equal(t, "pattern", validationErr.Errors[0].Type) + }) + + t.Run("should validate secret with invalid base64", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Credentials = map[string]string{ + "secret": "whsec_not-valid-base64!!!", + } + err := provider.Validate(context.Background(), &invalidDestination) + + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field) + assert.Equal(t, "pattern", validationErr.Errors[0].Type) + }) + + t.Run("should validate previous_secret without whsec prefix", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Credentials = map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "previous_secret": "not-a-whsec-secret", + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + } + err := provider.Validate(context.Background(), &invalidDestination) + + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.previous_secret", validationErr.Errors[0].Field) + assert.Equal(t, "pattern", validationErr.Errors[0].Type) + }) + + t.Run("should validate previous secret without invalid_at", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Credentials = map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + } + err := provider.Validate(context.Background(), &invalidDestination) + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.previous_secret_invalid_at", validationErr.Errors[0].Field) + assert.Equal(t, "required", validationErr.Errors[0].Type) + }) + + t.Run("should validate malformed previous_secret_invalid_at", func(t *testing.T) { + t.Parallel() + invalidDestination := validDestination + invalidDestination.Credentials = map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + "previous_secret_invalid_at": "not-a-timestamp", + } + err := provider.Validate(context.Background(), &invalidDestination) + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.previous_secret_invalid_at", validationErr.Errors[0].Field) + assert.Equal(t, "pattern", validationErr.Errors[0].Type) + }) + + t.Run("should validate valid destination with previous secret", func(t *testing.T) { + t.Parallel() + validDestWithPrevious := validDestination + validDestWithPrevious.Credentials = map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + "previous_secret_invalid_at": "2024-01-02T00:00:00Z", + } + assert.NoError(t, provider.Validate(context.Background(), &validDestWithPrevious)) + }) +} + +func TestStandardWebhookDestination_ComputeTarget(t *testing.T) { + t.Parallel() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + + t.Run("should return url as target", func(t *testing.T) { + t.Parallel() + destination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/webhook", + }), + ) + target := provider.ComputeTarget(&destination) + assert.Equal(t, "https://example.com/webhook", target.Target) + }) +} + +func TestStandardWebhookDestination_Preprocess(t *testing.T) { + t.Parallel() + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil) + require.NoError(t, err) + + t.Run("should generate default whsec secret if not provided", func(t *testing.T) { + t.Parallel() + destination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + ) + + err := provider.Preprocess(&destination, nil, &destregistry.PreprocessDestinationOpts{Role: "tenant"}) + require.NoError(t, err) + + // Verify that a whsec_ secret was generated + assert.True(t, strings.HasPrefix(destination.Credentials["secret"], "whsec_")) + + // Verify it's valid + assert.NoError(t, provider.Validate(context.Background(), &destination)) + }) + + t.Run("should preserve existing secret for admin", func(t *testing.T) { + t.Parallel() + destination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_CustomSecretBase64EncodedString", + }), + ) + + err := provider.Preprocess(&destination, nil, &destregistry.PreprocessDestinationOpts{Role: "admin"}) + require.NoError(t, err) + + // Verify that the custom secret was preserved + assert.Equal(t, "whsec_CustomSecretBase64EncodedString", destination.Credentials["secret"]) + }) + + t.Run("tenant should not be able to override existing secret", func(t *testing.T) { + t.Parallel() + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_CurrentSecretBase64EncodedString", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_CustomSecretBase64EncodedString", + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "tenant"}) + var validationErr *destregistry.ErrDestinationValidation + assert.ErrorAs(t, err, &validationErr) + assert.Equal(t, "credentials.secret", validationErr.Errors[0].Field) + assert.Equal(t, "forbidden", validationErr.Errors[0].Type) + }) + + t.Run("tenant should be able to rotate secret", func(t *testing.T) { + t.Parallel() + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_CurrentSecretBase64EncodedString", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "rotate_secret": "true", + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "tenant"}) + require.NoError(t, err) + + // Verify that the current secret became the previous secret + assert.Equal(t, "whsec_CurrentSecretBase64EncodedString", newDestination.Credentials["previous_secret"]) + + // Verify that a new secret was generated with whsec_ prefix + assert.NotEqual(t, "whsec_CurrentSecretBase64EncodedString", newDestination.Credentials["secret"]) + assert.True(t, strings.HasPrefix(newDestination.Credentials["secret"], "whsec_")) + assert.NotEmpty(t, newDestination.Credentials["secret"]) + + // Verify that previous_secret_invalid_at was set to ~24h from now + invalidAt, err := time.Parse(time.RFC3339, newDestination.Credentials["previous_secret_invalid_at"]) + require.NoError(t, err) + expectedTime := time.Now().Add(24 * time.Hour) + assert.WithinDuration(t, expectedTime, invalidAt, 5*time.Second) + }) + + t.Run("admin should be able to set previous_secret directly", func(t *testing.T) { + t.Parallel() + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"}) + require.NoError(t, err) + + // Verify that previous_secret was kept + assert.Equal(t, "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", newDestination.Credentials["previous_secret"]) + }) + + t.Run("should respect custom invalidation time during rotation", func(t *testing.T) { + t.Parallel() + customInvalidAt := time.Now().Add(48 * time.Hour).Format(time.RFC3339) + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_CurrentSecretBase64EncodedString", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "rotate_secret": "true", + "previous_secret_invalid_at": customInvalidAt, + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{}) + require.NoError(t, err) + + // Verify that the custom invalidation time was preserved + assert.Equal(t, customInvalidAt, newDestination.Credentials["previous_secret_invalid_at"]) + }) + + t.Run("should set default previous_secret_invalid_at when previous_secret is provided", func(t *testing.T) { + t.Parallel() + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"}) + require.NoError(t, err) + + // Verify that previous_secret_invalid_at was set to ~24h from now + invalidAt, err := time.Parse(time.RFC3339, newDestination.Credentials["previous_secret_invalid_at"]) + require.NoError(t, err) + expectedTime := time.Now().Add(24 * time.Hour) + assert.WithinDuration(t, expectedTime, invalidAt, 5*time.Second) + }) + + t.Run("should remove extra fields from credentials map", func(t *testing.T) { + t.Parallel() + originalDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", + }), + ) + + newDestination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "https://example.com/new", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", + "previous_secret": "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + "extra_field": "should be removed", + "another_extra": "also removed", + "rotate_secret": "false", + }), + ) + + // Merge both config and credentials to simulate handler behavior + newDestination.Config = maputil.MergeStringMaps(originalDestination.Config, newDestination.Config) + newDestination.Credentials = maputil.MergeStringMaps(originalDestination.Credentials, newDestination.Credentials) + + err := provider.Preprocess(&newDestination, &originalDestination, &destregistry.PreprocessDestinationOpts{Role: "admin"}) + require.NoError(t, err) + + // Verify that only expected fields are present + expectedFields := map[string]bool{ + "secret": true, + "previous_secret": true, + "previous_secret_invalid_at": true, + } + + // Check that only expected fields exist + for key := range newDestination.Credentials { + assert.True(t, expectedFields[key], "unexpected field %q found in credentials", key) + } + + // Check that all expected fields are present + assert.Equal(t, len(expectedFields), len(newDestination.Credentials), "credentials map has wrong number of fields") + + // Verify values are preserved for expected fields + assert.Equal(t, "whsec_Q3VycmVudFNlY3JldFN0cmluZw==", newDestination.Credentials["secret"]) + assert.Equal(t, "whsec_T2xkU2VjcmV0U3RyaW5nMTIz", newDestination.Credentials["previous_secret"]) + assert.NotEmpty(t, newDestination.Credentials["previous_secret_invalid_at"]) + }) +} diff --git a/internal/destregistry/providers/destwebhookstandard/secret.go b/internal/destregistry/providers/destwebhookstandard/secret.go new file mode 100644 index 00000000..1c6c30f2 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/secret.go @@ -0,0 +1,62 @@ +package destwebhookstandard + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "strings" +) + +const ( + SecretPrefix = "whsec_" + SecretLength = 32 // 32 bytes = 256 bits +) + +// validateSecret checks if a secret has the correct whsec_ prefix and valid base64 encoding +func validateSecret(secret string) error { + if !strings.HasPrefix(secret, SecretPrefix) { + return fmt.Errorf("secret must have %s prefix", SecretPrefix) + } + + encodedPart := strings.TrimPrefix(secret, SecretPrefix) + if encodedPart == "" { + return fmt.Errorf("secret is empty after prefix") + } + + if _, err := base64.StdEncoding.DecodeString(encodedPart); err != nil { + return fmt.Errorf("secret is not valid base64: %w", err) + } + + return nil +} + +// parseSecret extracts and decodes the secret portion after whsec_ prefix +// Returns the decoded bytes as a string for use with SignatureManager +func parseSecret(secret string) (string, error) { + if err := validateSecret(secret); err != nil { + return "", err + } + + encodedPart := strings.TrimPrefix(secret, SecretPrefix) + decoded, err := base64.StdEncoding.DecodeString(encodedPart) + if err != nil { + return "", fmt.Errorf("failed to decode secret: %w", err) + } + + // Return as string - SignatureManager will convert to []byte for HMAC + return string(decoded), nil +} + +// generateStandardSecret creates a cryptographically secure random secret in Standard Webhooks format +// Format: whsec_ +func generateStandardSecret() (string, error) { + // Generate 32 random bytes (256 bits) + randomBytes := make([]byte, SecretLength) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random secret: %w", err) + } + + // Encode and prefix + encoded := base64.StdEncoding.EncodeToString(randomBytes) + return SecretPrefix + encoded, nil +} diff --git a/internal/destregistry/providers/destwebhookstandard/secret_test.go b/internal/destregistry/providers/destwebhookstandard/secret_test.go new file mode 100644 index 00000000..ce9d4931 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/secret_test.go @@ -0,0 +1,143 @@ +package destwebhookstandard + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSecret(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret string + wantErr bool + }{ + { + name: "valid whsec secret", + secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + wantErr: false, + }, + { + name: "missing whsec prefix", + secret: "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + wantErr: true, + }, + { + name: "empty after prefix", + secret: "whsec_", + wantErr: true, + }, + { + name: "invalid base64", + secret: "whsec_not-valid-base64!!!", + wantErr: true, + }, + { + name: "empty string", + secret: "", + wantErr: true, + }, + { + name: "wrong prefix", + secret: "whsk_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateSecret(tt.secret) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestParseSecret(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret string + wantErr bool + }{ + { + name: "valid whsec secret", + secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + wantErr: false, + }, + { + name: "invalid prefix", + secret: "invalid_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + wantErr: true, + }, + { + name: "invalid base64", + secret: "whsec_not-valid-base64!!!", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := parseSecret(tt.secret) + if tt.wantErr { + assert.Error(t, err) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.NotEmpty(t, result) + + // Verify that the result is the decoded version + encodedPart := strings.TrimPrefix(tt.secret, SecretPrefix) + decoded, _ := base64.StdEncoding.DecodeString(encodedPart) + assert.Equal(t, string(decoded), result) + } + }) + } +} + +func TestGenerateStandardSecret(t *testing.T) { + t.Parallel() + + t.Run("generates valid whsec secret", func(t *testing.T) { + t.Parallel() + secret, err := generateStandardSecret() + require.NoError(t, err) + + // Should have whsec_ prefix + assert.True(t, strings.HasPrefix(secret, SecretPrefix)) + + // Should be valid base64 after prefix + err = validateSecret(secret) + assert.NoError(t, err) + + // Should decode to 32 bytes + encodedPart := strings.TrimPrefix(secret, SecretPrefix) + decoded, err := base64.StdEncoding.DecodeString(encodedPart) + require.NoError(t, err) + assert.Equal(t, SecretLength, len(decoded)) + }) + + t.Run("generates unique secrets", func(t *testing.T) { + t.Parallel() + secret1, err := generateStandardSecret() + require.NoError(t, err) + + secret2, err := generateStandardSecret() + require.NoError(t, err) + + // Should generate different secrets + assert.NotEqual(t, secret1, secret2) + }) +}