From 487f152cac21c3ee10991e772063f288dc93547b Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Thu, 9 Oct 2025 21:13:06 +0700 Subject: [PATCH 1/9] feat: destwebhookstandard --- .../webhook_standard/instructions.md | 16 + .../providers/webhook_standard/metadata.json | 18 + .../destwebhookstandard/assert_test.go | 61 ++ .../destwebhookstandard.go | 630 ++++++++++++++++++ .../destwebhookstandard_config_test.go | 55 ++ .../destwebhookstandard_publish_test.go | 350 ++++++++++ .../destwebhookstandard_validate_test.go | 460 +++++++++++++ .../providers/destwebhookstandard/secret.go | 62 ++ .../destwebhookstandard/secret_test.go | 143 ++++ 9 files changed, 1795 insertions(+) create mode 100644 internal/destregistry/metadata/providers/webhook_standard/instructions.md create mode 100644 internal/destregistry/metadata/providers/webhook_standard/metadata.json create mode 100644 internal/destregistry/providers/destwebhookstandard/assert_test.go create mode 100644 internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go create mode 100644 internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go create mode 100644 internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go create mode 100644 internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go create mode 100644 internal/destregistry/providers/destwebhookstandard/secret.go create mode 100644 internal/destregistry/providers/destwebhookstandard/secret_test.go 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..fc608516 --- /dev/null +++ b/internal/destregistry/metadata/providers/webhook_standard/metadata.json @@ -0,0 +1,18 @@ +{ + "type": "webhook_standard", + "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": "Standard Webhooks", + "link": "https://github.com/standard-webhooks/standard-webhooks", + "description": "Send events as Standard Webhooks (https://www.standardwebhooks.com/) compliant webhooks with whsec_ secrets and msg_ prefixed message IDs.", + "icon": "" +} diff --git a/internal/destregistry/providers/destwebhookstandard/assert_test.go b/internal/destregistry/providers/destwebhookstandard/assert_test.go new file mode 100644 index 00000000..0a1531a9 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/assert_test.go @@ -0,0 +1,61 @@ +package destwebhookstandard_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + + testsuite "github.com/hookdeck/outpost/internal/destregistry/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertValidStandardWebhookSignature verifies a Standard Webhooks signature +func assertValidStandardWebhookSignature(t testsuite.TestingT, secret, msgID, timestamp string, body []byte, signatureHeader string) { + t.Helper() + + // Parse whsec_ secret + 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") +} + +// 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..ba66b933 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go @@ -0,0 +1,630 @@ +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: Fixed "webhook-id", "webhook-timestamp", "webhook-signature" + +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 +} + +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) { + d.proxyURL = proxyURL + } +} + +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, + } + 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, + }, 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 +} + +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 { + bodyBytes, _ := io.ReadAll(resp.Body) + delivery := &destregistry.Delivery{ + Status: "failed", + Code: fmt.Sprintf("%d", resp.StatusCode), + } + parseResponse(delivery, resp) + return delivery, destregistry.NewErrDestinationPublishAttempt( + fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)), + "webhook_standard", + map[string]interface{}{ + "status": resp.StatusCode, + "body": string(bodyBytes), + }) + } + + 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 + req.Header.Set("webhook-id", messageID) + req.Header.Set("webhook-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("webhook-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 webhook- prefix to be consistent with Standard Webhooks naming + req.Header.Set("webhook-"+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..11374047 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go @@ -0,0 +1,55 @@ +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 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"), + ) + 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..810fc632 --- /dev/null +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go @@ -0,0 +1,350 @@ +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 +} + +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")) + + // Verify Standard Webhooks headers + webhookID := req.Header.Get("webhook-id") + assert.NotEmpty(t, webhookID, "webhook-id should be present") + // Note: webhook-id format depends on event.ID format (user-provided) + + webhookTimestamp := req.Header.Get("webhook-timestamp") + assert.NotEmpty(t, webhookTimestamp, "webhook-timestamp should be present") + testsuite.AssertTimestampIsUnixSeconds(t, webhookTimestamp) + + webhookSignature := req.Header.Get("webhook-signature") + assert.NotEmpty(t, webhookSignature, "webhook-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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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") + } +} 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..d1ce42ca --- /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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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_standard"), + 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) + }) +} From a0807d870060155d90b10a9598fec951dc41577d Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Thu, 9 Oct 2025 21:38:07 +0700 Subject: [PATCH 2/9] chore: destination webhook mode --- internal/config/destinations.go | 24 +++++---- internal/destregistry/providers/default.go | 63 ++++++++++++++-------- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/internal/config/destinations.go b/internal/config/destinations.go index 13f00c39..ce93ba94 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 custom headers added to webhook requests (e.g., 'X-MyOrg-'). Only applies to 'default' 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/providers/default.go b/internal/destregistry/providers/default.go index 433a5e90..223da2b4 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,47 @@ 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), + } + if opts.Webhook.ProxyURL != "" { + webhookStandardOpts = append(webhookStandardOpts, + destwebhookstandard.WithProxyURL(opts.Webhook.ProxyURL), + ) + } + 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)) From 9073467566bf726d9914107030cbe05a1d8be526 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 14:55:22 +0700 Subject: [PATCH 3/9] fix: metadata.json --- .../metadata/providers/webhook_standard/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/destregistry/metadata/providers/webhook_standard/metadata.json b/internal/destregistry/metadata/providers/webhook_standard/metadata.json index fc608516..695f4f50 100644 --- a/internal/destregistry/metadata/providers/webhook_standard/metadata.json +++ b/internal/destregistry/metadata/providers/webhook_standard/metadata.json @@ -1,5 +1,5 @@ { - "type": "webhook_standard", + "type": "webhook", "config_fields": [ { "key": "url", From c479eef2a4d955e699d84f43d1ded657480f1dfd Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 15:09:39 +0700 Subject: [PATCH 4/9] fix: destination type --- .../destwebhookstandard_publish_test.go | 10 +++--- .../destwebhookstandard_validate_test.go | 32 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go index 810fc632..735dd791 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go @@ -133,7 +133,7 @@ func (s *StandardWebhookPublishSuite) setupBasicSuite() { require.NoError(s.T(), err) dest := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": consumer.server.URL + "/webhook", }), @@ -165,7 +165,7 @@ func (s *StandardWebhookPublishSuite) setupMultipleSecretsSuite() { now := time.Now() invalidAt := now.Add(24 * time.Hour) dest := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": consumer.server.URL + "/webhook", }), @@ -199,7 +199,7 @@ func (s *StandardWebhookPublishSuite) setupExpiredSecretsSuite() { now := time.Now() invalidAt := now.Add(-1 * time.Hour) // Previous secret is already invalid dest := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": consumer.server.URL + "/webhook", }), @@ -261,7 +261,7 @@ func TestStandardWebhookPublisher_SignatureFormat(t *testing.T) { require.NoError(t, err) dest := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": consumer.server.URL + "/webhook", }), @@ -312,7 +312,7 @@ func TestStandardWebhookPublisher_MessageIDFormat(t *testing.T) { require.NoError(t, err) dest := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": consumer.server.URL + "/webhook", }), diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go index d1ce42ca..82e4b4fc 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_validate_test.go @@ -18,7 +18,7 @@ func TestStandardWebhookDestination_Validate(t *testing.T) { t.Parallel() validDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -167,7 +167,7 @@ func TestStandardWebhookDestination_ComputeTarget(t *testing.T) { t.Run("should return url as target", func(t *testing.T) { t.Parallel() destination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/webhook", }), @@ -186,7 +186,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { t.Run("should generate default whsec secret if not provided", func(t *testing.T) { t.Parallel() destination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -205,7 +205,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { t.Run("should preserve existing secret for admin", func(t *testing.T) { t.Parallel() destination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -224,7 +224,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { 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_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -234,7 +234,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), @@ -257,7 +257,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { t.Run("tenant should be able to rotate secret", func(t *testing.T) { t.Parallel() originalDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -267,7 +267,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), @@ -301,7 +301,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { 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_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -311,7 +311,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), @@ -335,7 +335,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { t.Parallel() customInvalidAt := time.Now().Add(48 * time.Hour).Format(time.RFC3339) originalDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -345,7 +345,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), @@ -369,7 +369,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { 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_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -379,7 +379,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), @@ -406,7 +406,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { t.Run("should remove extra fields from credentials map", func(t *testing.T) { t.Parallel() originalDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com", }), @@ -416,7 +416,7 @@ func TestStandardWebhookDestination_Preprocess(t *testing.T) { ) newDestination := testutil.DestinationFactory.Any( - testutil.DestinationFactory.WithType("webhook_standard"), + testutil.DestinationFactory.WithType("webhook"), testutil.DestinationFactory.WithConfig(map[string]string{ "url": "https://example.com/new", }), From 1adf6917c23ef6f30850aa20c8d595b1eb447844 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 15:10:11 +0700 Subject: [PATCH 5/9] test: verify delivery using official standard-webhooks sdk --- go.mod | 1 + go.sum | 2 ++ .../destwebhookstandard/assert_test.go | 20 +++++++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) 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/destregistry/providers/destwebhookstandard/assert_test.go b/internal/destregistry/providers/destwebhookstandard/assert_test.go index 0a1531a9..c82cbe20 100644 --- a/internal/destregistry/providers/destwebhookstandard/assert_test.go +++ b/internal/destregistry/providers/destwebhookstandard/assert_test.go @@ -5,18 +5,34 @@ import ( "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() - // Parse whsec_ secret + // 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") @@ -40,7 +56,7 @@ func assertValidStandardWebhookSignature(t testsuite.TestingT, secret, msgID, ti } } - assert.True(t, found, "no valid signature found in header") + assert.True(t, found, "no valid signature found in header (manual verification)") } // assertSignatureFormat verifies the signature header format From 6c6aa19f0b856aee4ec0b521cd1e4e2d4492ef3a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 18:26:34 +0700 Subject: [PATCH 6/9] feat: configurable webhook header prefix --- internal/config/destinations.go | 2 +- internal/destregistry/providers/default.go | 7 +- .../destwebhookstandard.go | 46 +++++++---- .../destwebhookstandard_config_test.go | 12 +++ .../destwebhookstandard_publish_test.go | 81 +++++++++++++++++-- 5 files changed, 121 insertions(+), 27 deletions(-) diff --git a/internal/config/destinations.go b/internal/config/destinations.go index ce93ba94..1d9aa926 100644 --- a/internal/config/destinations.go +++ b/internal/config/destinations.go @@ -40,7 +40,7 @@ type DestinationWebhookConfig struct { // 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-'). Only applies to 'default' mode." 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"` diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go index 223da2b4..4622b97f 100644 --- a/internal/destregistry/providers/default.go +++ b/internal/destregistry/providers/default.go @@ -54,11 +54,8 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina // Standard Webhooks mode - register webhook_standard as "webhook" webhookStandardOpts := []destwebhookstandard.Option{ destwebhookstandard.WithUserAgent(opts.UserAgent), - } - if opts.Webhook.ProxyURL != "" { - webhookStandardOpts = append(webhookStandardOpts, - destwebhookstandard.WithProxyURL(opts.Webhook.ProxyURL), - ) + destwebhookstandard.WithProxyURL(opts.Webhook.ProxyURL), + destwebhookstandard.WithHeaderPrefix(opts.Webhook.HeaderPrefix), } webhookStandard, err := destwebhookstandard.New(loader, basePublisherOpts, webhookStandardOpts...) if err != nil { diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go index ba66b933..27278b62 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go @@ -12,7 +12,10 @@ signature management infrastructure (SignatureManager). The key differences are: 2. Header Names: - destwebhook: Customizable via templates - - destwebhookstandard: Fixed "webhook-id", "webhook-timestamp", "webhook-signature" + - 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 @@ -65,8 +68,9 @@ import ( type StandardWebhookDestination struct { *destregistry.BaseProvider - userAgent string - proxyURL string + userAgent string + proxyURL string + headerPrefix string // Prefix for metadata headers (defaults to "webhook-") } type StandardWebhookDestinationConfig struct { @@ -94,7 +98,18 @@ func WithUserAgent(userAgent string) Option { // WithProxyURL sets the proxy URL for the webhook request func WithProxyURL(proxyURL string) Option { return func(d *StandardWebhookDestination) { - d.proxyURL = proxyURL + 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 + } } } @@ -105,6 +120,7 @@ func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePu } destination := &StandardWebhookDestination{ BaseProvider: base, + headerPrefix: "webhook-", // Default to Standard Webhooks spec } for _, opt := range opts { opt(destination) @@ -212,6 +228,7 @@ func (d *StandardWebhookDestination) CreatePublisher(ctx context.Context, destin url: config.URL, secrets: secrets, sm: sm, + headerPrefix: d.headerPrefix, }, nil } @@ -483,10 +500,11 @@ func (d *StandardWebhookDestination) Preprocess(newDestination *models.Destinati type StandardWebhookPublisher struct { *destregistry.BasePublisher - httpClient *http.Client - url string - secrets []destwebhook.WebhookSecret - sm *destwebhook.SignatureManager + httpClient *http.Client + url string + secrets []destwebhook.WebhookSecret + sm *destwebhook.SignatureManager + headerPrefix string } func (p *StandardWebhookPublisher) Close() error { @@ -559,9 +577,9 @@ func (p *StandardWebhookPublisher) Format(ctx context.Context, event *models.Eve // TODO: Support configurable ID generator/template (e.g., "msg_" prefix) messageID := event.ID - // Set Standard Webhooks headers - req.Header.Set("webhook-id", messageID) - req.Header.Set("webhook-timestamp", strconv.FormatInt(now.Unix(), 10)) + // 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{ @@ -571,7 +589,7 @@ func (p *StandardWebhookPublisher) Format(ctx context.Context, event *models.Eve Body: string(rawBody), }) if signatureHeader != "" { - req.Header.Set("webhook-signature", signatureHeader) + req.Header.Set(p.headerPrefix+"signature", signatureHeader) } // Add event metadata as custom headers @@ -583,8 +601,8 @@ func (p *StandardWebhookPublisher) Format(ctx context.Context, event *models.Eve if key == "event-id" || key == "timestamp" { continue } - // Add with webhook- prefix to be consistent with Standard Webhooks naming - req.Header.Set("webhook-"+key, value) + // Add with configured prefix (defaults to "webhook-") + req.Header.Set(p.headerPrefix+key, value) } // Also add custom event metadata without prefix (user-defined metadata) diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go index 11374047..dc9005ad 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go @@ -41,6 +41,17 @@ func TestNew(t *testing.T) { 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( @@ -48,6 +59,7 @@ func TestNew(t *testing.T) { 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 index 735dd791..6d3539d3 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go @@ -78,6 +78,7 @@ func (c *StandardWebhookConsumer) Close() error { 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) { @@ -87,17 +88,23 @@ func (a *StandardWebhookAsserter) AssertMessage(t testsuite.TestingT, msg testsu assert.Equal(t, "POST", req.Method) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - // Verify Standard Webhooks headers - webhookID := req.Header.Get("webhook-id") - assert.NotEmpty(t, webhookID, "webhook-id should be present") + // 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("webhook-timestamp") - assert.NotEmpty(t, webhookTimestamp, "webhook-timestamp should be present") + webhookTimestamp := req.Header.Get(prefix + "timestamp") + assert.NotEmpty(t, webhookTimestamp, prefix+"timestamp should be present") testsuite.AssertTimestampIsUnixSeconds(t, webhookTimestamp) - webhookSignature := req.Header.Get("webhook-signature") - assert.NotEmpty(t, webhookSignature, "webhook-signature should be present") + 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) @@ -348,3 +355,63 @@ func TestStandardWebhookPublisher_MessageIDFormat(t *testing.T) { 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") + } +} From cac2c6850421a8fa1f6eb31f46185e74c29e3f2d Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 18:36:42 +0700 Subject: [PATCH 7/9] docs: generate config --- docs/pages/references/configuration.mdx | 40 ++++++++++++++----------- 1 file changed, 22 insertions(+), 18 deletions(-) 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 \",\"}}" From 7fc13410adb449554f79e864596063d29660e22a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 19:00:28 +0700 Subject: [PATCH 8/9] fix: prevent response body from being consumed twice in webhook error handling When webhook requests fail (status >= 400), the response body was being read twice - once with io.ReadAll() and again in parseResponse(). Since HTTP response bodies are streams, the second read would get an empty result, causing delivery.Response to have an empty body instead of the actual error message. This fix removes the duplicate read and lets parseResponse() handle all body reading. The parsed body is then extracted from delivery.Response for error metadata. Affects: - internal/destregistry/providers/destwebhook/destwebhook.go - internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go Impact: Failed webhook deliveries will now correctly capture and display the actual error response body from endpoints, improving debuggability. Or a more concise version: fix: prevent double-read of webhook response body on error Response body was consumed twice in error path (status >= 400): first by io.ReadAll(), then by parseResponse(). This caused empty bodies in delivery responses, hiding actual error messages. Fixed by removing the first read and extracting the body from delivery.Response after parseResponse() completes. Fixes both destwebhook and destwebhookstandard providers. --- .../providers/destwebhook/destwebhook.go | 21 ++++++++++++++++--- .../destwebhookstandard.go | 21 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) 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/destwebhookstandard.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go index 27278b62..61bd5f96 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go @@ -533,18 +533,33 @@ func (p *StandardWebhookPublisher) Publish(ctx context.Context, event *models.Ev 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_standard", map[string]interface{}{ "status": resp.StatusCode, - "body": string(bodyBytes), + "body": bodyStr, }) } From 0c29dd6d441ecb8495326969c3389185162dfb41 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 10 Oct 2025 19:01:38 +0700 Subject: [PATCH 9/9] chore: update metadata.json to be consistent with webhook destination --- .../metadata/providers/webhook_standard/metadata.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/destregistry/metadata/providers/webhook_standard/metadata.json b/internal/destregistry/metadata/providers/webhook_standard/metadata.json index 695f4f50..377ac49c 100644 --- a/internal/destregistry/metadata/providers/webhook_standard/metadata.json +++ b/internal/destregistry/metadata/providers/webhook_standard/metadata.json @@ -11,8 +11,8 @@ } ], "credential_fields": [], - "label": "Standard Webhooks", - "link": "https://github.com/standard-webhooks/standard-webhooks", - "description": "Send events as Standard Webhooks (https://www.standardwebhooks.com/) compliant webhooks with whsec_ secrets and msg_ prefixed message IDs.", + "label": "Webhook", + "link": "https://hookdeck.com/webhooks/guides/what-are-webhooks-how-they-work", + "description": "Send events as webhooks (HTTP POST).", "icon": "" }