package destwebhook import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "github.com/hookdeck/outpost/internal/destregistry" "github.com/hookdeck/outpost/internal/destregistry/metadata" "github.com/hookdeck/outpost/internal/models" ) const ( DefaultEncoding = "hex" DefaultAlgorithm = "hmac-sha256" ) // Reserved headers that cannot be set via custom_headers var reservedHeaders = map[string]bool{ "content-type": true, "content-length": true, "host": true, "connection": true, "user-agent": true, } // Valid header name pattern (RFC 7230 token) var headerNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) // ValidateCustomHeaders validates custom header names and values func ValidateCustomHeaders(headers map[string]string) error { if len(headers) == 0 { return nil } var errors []destregistry.ValidationErrorDetail for name, value := range headers { // Check header name format if !headerNameRegex.MatchString(name) { errors = append(errors, destregistry.ValidationErrorDetail{ Field: fmt.Sprintf("config.custom_headers.%s", name), Type: "pattern", }) continue } // Check reserved headers (case-insensitive) if reservedHeaders[strings.ToLower(name)] { errors = append(errors, destregistry.ValidationErrorDetail{ Field: fmt.Sprintf("config.custom_headers.%s", name), Type: "forbidden", }) continue } // Check value is not empty if value == "" { errors = append(errors, destregistry.ValidationErrorDetail{ Field: fmt.Sprintf("config.custom_headers.%s", name), Type: "required", }) } } if len(errors) > 0 { return destregistry.NewErrDestinationValidation(errors) } return nil } type WebhookDestination struct { *destregistry.BaseProvider headerPrefix string userAgent string proxyURL string signatureContentTemplate string signatureHeaderTemplate string disableEventIDHeader bool disableSignatureHeader bool disableTimestampHeader bool disableTopicHeader bool encoding string algorithm string } type WebhookDestinationConfig struct { URL string CustomHeaders map[string]string } type WebhookSecret struct { Key string `json:"key"` CreatedAt time.Time `json:"created_at"` InvalidAt *time.Time `json:"invalid_at,omitempty"` } type WebhookDestinationCredentials struct { Secret string `json:"secret"` PreviousSecret string `json:"previous_secret,omitempty"` PreviousSecretInvalidAt time.Time `json:"previous_secret_invalid_at,omitempty"` } var _ destregistry.Provider = (*WebhookDestination)(nil) // Option is a functional option for configuring WebhookDestination type Option func(*WebhookDestination) // WithHeaderPrefix sets a custom prefix for webhook request headers func WithHeaderPrefix(prefix string) Option { return func(w *WebhookDestination) { if prefix != "" { w.headerPrefix = prefix } } } // WithUserAgent sets the user agent for the webhook request func WithUserAgent(userAgent string) Option { return func(w *WebhookDestination) { w.userAgent = userAgent } } func WithProxyURL(proxyURL string) Option { return func(w *WebhookDestination) { w.proxyURL = proxyURL } } // Add these options after the existing Option definitions func WithDisableDefaultEventIDHeader(disable bool) Option { return func(w *WebhookDestination) { w.disableEventIDHeader = disable } } func WithDisableDefaultSignatureHeader(disable bool) Option { return func(w *WebhookDestination) { w.disableSignatureHeader = disable } } func WithDisableDefaultTimestampHeader(disable bool) Option { return func(w *WebhookDestination) { w.disableTimestampHeader = disable } } func WithDisableDefaultTopicHeader(disable bool) Option { return func(w *WebhookDestination) { w.disableTopicHeader = disable } } func WithSignatureContentTemplate(template string) Option { return func(w *WebhookDestination) { w.signatureContentTemplate = template } } func WithSignatureHeaderTemplate(template string) Option { return func(w *WebhookDestination) { w.signatureHeaderTemplate = template } } func WithSignatureEncoding(encoding string) Option { return func(w *WebhookDestination) { w.encoding = encoding } } func WithSignatureAlgorithm(algorithm string) Option { return func(w *WebhookDestination) { w.algorithm = algorithm } } func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePublisherOption, opts ...Option) (*WebhookDestination, error) { base, err := destregistry.NewBaseProvider(loader, "webhook", basePublisherOpts...) if err != nil { return nil, err } destination := &WebhookDestination{ BaseProvider: base, headerPrefix: "x-outpost-", encoding: DefaultEncoding, algorithm: DefaultAlgorithm, } for _, opt := range opts { opt(destination) } return destination, nil } func (d *WebhookDestination) ComputeTarget(destination *models.Destination) destregistry.DestinationTarget { return destregistry.DestinationTarget{ Target: destination.Config["url"], TargetURL: "", } } // ObfuscateDestination overrides the base implementation to handle webhook secrets func (d *WebhookDestination) 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 using base provider's logic for key, value := range destination.Config { result.Config[key] = value } // Check if previous_secret has expired skipPreviousSecret := false if invalidAtStr := destination.Credentials["previous_secret_invalid_at"]; invalidAtStr != "" { if invalidAt, err := time.Parse(time.RFC3339, invalidAtStr); err == nil { if time.Now().After(invalidAt) { skipPreviousSecret = true } } } // Copy credentials, omitting expired previous_secret fields // NOTE: Webhook 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 (e.g. AWS keys) // TODO: Implement proper secret obfuscation later if needed for key, value := range destination.Credentials { if skipPreviousSecret && (key == "previous_secret" || key == "previous_secret_invalid_at") { continue } result.Credentials[key] = value } return &result } func (d *WebhookDestination) Validate(ctx context.Context, destination *models.Destination) error { if _, _, err := d.resolveConfig(ctx, destination); err != nil { return err } return nil } func (d *WebhookDestination) GetSignatureEncoding() string { return d.encoding } func (d *WebhookDestination) GetSignatureAlgorithm() string { return d.algorithm } func (d *WebhookDestination) CreatePublisher(ctx context.Context, destination *models.Destination) (destregistry.Publisher, error) { config, creds, err := d.resolveConfig(ctx, destination) if err != nil { return nil, err } // Convert credentials to WebhookSecret format now := time.Now() secrets := []WebhookSecret{ { Key: creds.Secret, CreatedAt: now, }, } if creds.PreviousSecret != "" { secrets = append(secrets, WebhookSecret{ Key: creds.PreviousSecret, CreatedAt: now.Add(-1 * time.Hour), // Set to 1 hour before current secret InvalidAt: &creds.PreviousSecretInvalidAt, }) } sm := NewSignatureManager( secrets, WithSignatureFormatter(NewSignatureFormatter(d.signatureContentTemplate)), WithHeaderFormatter(NewHeaderFormatter(d.signatureHeaderTemplate)), WithEncoder(GetEncoder(d.encoding)), WithAlgorithm(GetAlgorithm(d.algorithm)), ) 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 &WebhookPublisher{ BasePublisher: d.BaseProvider.NewPublisher(destregistry.WithDeliveryMetadata(destination.DeliveryMetadata)), httpClient: httpClient, url: config.URL, headerPrefix: d.headerPrefix, secrets: secrets, sm: sm, disableEventIDHeader: d.disableEventIDHeader, disableSignatureHeader: d.disableSignatureHeader, disableTimestampHeader: d.disableTimestampHeader, disableTopicHeader: d.disableTopicHeader, customHeaders: config.CustomHeaders, }, nil } func (d *WebhookDestination) resolveConfig(ctx context.Context, destination *models.Destination) (*WebhookDestinationConfig, *WebhookDestinationCredentials, error) { if err := d.BaseProvider.Validate(ctx, destination); err != nil { return nil, nil, err } config := &WebhookDestinationConfig{ URL: destination.Config["url"], } // Parse custom headers from config if headersJSON, ok := destination.Config["custom_headers"]; ok && headersJSON != "" { if err := json.Unmarshal([]byte(headersJSON), &config.CustomHeaders); err != nil { return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ Field: "config.custom_headers", Type: "invalid", }}) } if len(config.CustomHeaders) == 0 { return nil, nil, destregistry.NewErrDestinationValidation([]destregistry.ValidationErrorDetail{{ Field: "config.custom_headers", Type: "invalid", }}) } if err := ValidateCustomHeaders(config.CustomHeaders); err != nil { return nil, nil, err } } // Parse credentials directly from map creds := &WebhookDestinationCredentials{ 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", }}) } // 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 } // If previous secret is provided, validate invalidation time if creds.PreviousSecret != "" && creds.PreviousSecretInvalidAt.IsZero() { 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.IsZero() && 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 *WebhookDestination) 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 := generateSignatureSecret() 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 *WebhookDestination) 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 *WebhookDestination) 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 := generateSignatureSecret() if err != nil { return nil, err } return map[string]string{ "secret": secret, }, nil } // validateAndSanitizeCredentials performs final validation and cleanup func (d *WebhookDestination) 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 *WebhookDestination) 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 WebhookPublisher struct { *destregistry.BasePublisher httpClient *http.Client url string headerPrefix string secrets []WebhookSecret sm *SignatureManager disableEventIDHeader bool disableSignatureHeader bool disableTimestampHeader bool disableTopicHeader bool customHeaders map[string]string } func (p *WebhookPublisher) Close() error { p.BasePublisher.StartClose() return nil } func (p *WebhookPublisher) 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 } result := ExecuteHTTPRequest(ctx, p.httpClient, httpReq, "webhook") return result.Delivery, result.Error } // Format is a helper function to format the event data into an HTTP request. func (p *WebhookPublisher) 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") // Add custom headers FIRST (so metadata can override if there's a conflict) for key, value := range p.customHeaders { req.Header.Set(key, value) } // Get merged metadata (system + event metadata) using BasePublisher metadata := p.BasePublisher.MakeMetadata(event, now) // Add headers from metadata, respecting disable flags for key, value := range metadata { // Check if this specific system header should be disabled switch key { case "timestamp": if p.disableTimestampHeader { continue } case "event-id": if p.disableEventIDHeader { continue } case "topic": if p.disableTopicHeader { continue } } // Add the header with the appropriate prefix req.Header.Set(p.headerPrefix+key, value) } // Add signature header if not disabled if !p.disableSignatureHeader { signatureHeader := p.sm.GenerateSignatureHeader(SignaturePayload{ EventID: event.ID, Topic: event.Topic, Timestamp: now, Body: string(rawBody), }) if signatureHeader != "" { req.Header.Set(p.headerPrefix+"signature", signatureHeader) } } return req, nil } // generateSignatureSecret creates a cryptographically secure random secret suitable for HMAC signatures. // The secret is 32 bytes (256 bits) encoded as a hex string. func generateSignatureSecret() (string, error) { // Generate a random 32-byte hex string randomBytes := make([]byte, 32) if _, err := rand.Read(randomBytes); err != nil { return "", fmt.Errorf("failed to generate random secret: %w", err) } return hex.EncodeToString(randomBytes), nil } // GetEncoder returns the appropriate SignatureEncoder for the given encoding func GetEncoder(encoding string) SignatureEncoder { switch encoding { case "base64": return Base64Encoder{} case "hex": return HexEncoder{} default: return HexEncoder{} // default to hex } } // GetAlgorithm returns the appropriate SigningAlgorithm for the given algorithm name func GetAlgorithm(algorithm string) SigningAlgorithm { switch algorithm { case "hmac-sha1": return NewHmacSHA1() case "hmac-sha256": return NewHmacSHA256() default: return NewHmacSHA256() // default to hmac-sha256 } } // 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 } }