From 83c547fe94c9be4c9035a351513ba8e31c4033cc Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Thu, 26 Mar 2026 03:06:09 +0700 Subject: [PATCH] feat: support empty webhook header prefix (#575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow disabling the webhook header prefix by setting it to whitespace (e.g. " "). The provider trims whitespace, so " " effectively sets the prefix to empty string. - Config `toConfig()` converts string→*string: empty=""→nil (default), non-empty→&value - `WithHeaderPrefix(*string)`: nil keeps default, non-nil applies with TrimSpace - Add tests for nil/empty/whitespace/custom prefix in both providers Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/config/config_test.go | 22 +++++ internal/config/destinations.go | 14 +++- internal/destregistry/providers/default.go | 2 +- .../providers/destwebhook/destwebhook.go | 11 ++- .../destwebhook/destwebhook_publish_test.go | 80 ++++++++++++++++++- .../destwebhookstandard.go | 11 ++- .../destwebhookstandard_config_test.go | 4 +- .../destwebhookstandard_publish_test.go | 75 ++++++++++++++++- 8 files changed, 201 insertions(+), 18 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4d838909a..5968e3c85 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -359,6 +359,28 @@ destinations: }, want: "x-env-", }, + { + name: "whitespace disables prefix via yaml", + files: map[string][]byte{ + "config.yaml": []byte(` +destinations: + webhook: + header_prefix: " " +`), + }, + envVars: map[string]string{ + "CONFIG": "config.yaml", + }, + want: " ", + }, + { + name: "whitespace disables prefix via env", + files: map[string][]byte{}, + envVars: map[string]string{ + "DESTINATIONS_WEBHOOK_HEADER_PREFIX": " ", + }, + want: " ", + }, } for _, tt := range tests { diff --git a/internal/config/destinations.go b/internal/config/destinations.go index f14562447..eaa952422 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 metadata headers added to webhook requests. Defaults to 'x-outpost-' in 'default' mode and 'webhook-' in 'standard' 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. Set to whitespace (e.g. ' ') to disable the prefix entirely." 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"` @@ -58,10 +58,20 @@ func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookCo if mode == "" { mode = "default" } + + // Convert HeaderPrefix string to *string for the provider: + // - empty string (zero value, unset) → nil → provider uses its default prefix + // - non-empty string (including whitespace like " ") → &value → provider applies it + // (whitespace is trimmed by the provider, so " " effectively disables the prefix) + var headerPrefix *string + if c.HeaderPrefix != "" { + headerPrefix = &c.HeaderPrefix + } + return &destregistrydefault.DestWebhookConfig{ Mode: mode, ProxyURL: c.ProxyURL, - HeaderPrefix: c.HeaderPrefix, + HeaderPrefix: headerPrefix, DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader, DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader, DisableDefaultTimestampHeader: c.DisableDefaultTimestampHeader, diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go index bdbe8d81d..5901b95e3 100644 --- a/internal/destregistry/providers/default.go +++ b/internal/destregistry/providers/default.go @@ -17,7 +17,7 @@ import ( type DestWebhookConfig struct { Mode string ProxyURL string - HeaderPrefix string + HeaderPrefix *string DisableDefaultEventIDHeader bool DisableDefaultSignatureHeader bool DisableDefaultTimestampHeader bool diff --git a/internal/destregistry/providers/destwebhook/destwebhook.go b/internal/destregistry/providers/destwebhook/destwebhook.go index 824b9d84a..46b7b01bb 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook.go +++ b/internal/destregistry/providers/destwebhook/destwebhook.go @@ -119,11 +119,14 @@ 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 { +// WithHeaderPrefix sets a custom prefix for webhook request headers. +// When prefix is nil, the default prefix is used. +// When prefix is non-nil, its value is used (after trimming whitespace), +// allowing an empty string to disable the prefix entirely. +func WithHeaderPrefix(prefix *string) Option { return func(w *WebhookDestination) { - if prefix != "" { - w.headerPrefix = prefix + if prefix != nil { + w.headerPrefix = strings.TrimSpace(*prefix) } } } diff --git a/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go b/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go index 6817ab6ce..4b0d9aa41 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go +++ b/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go @@ -279,13 +279,12 @@ func (s *WebhookPublishSuite) setupExpiredSecretsSuite() { // Custom header prefix test configuration func (s *WebhookPublishSuite) setupCustomHeaderSuite() { - const customPrefix = "x-custom-" - consumer := NewWebhookConsumer(customPrefix) + consumer := NewWebhookConsumer("x-custom-") provider, err := destwebhook.New( testutil.Registry.MetadataLoader(), nil, - destwebhook.WithHeaderPrefix(customPrefix), + destwebhook.WithHeaderPrefix(new("x-custom-")), ) require.NoError(s.T(), err) @@ -304,7 +303,7 @@ func (s *WebhookPublishSuite) setupCustomHeaderSuite() { Dest: &dest, Consumer: consumer, Asserter: &WebhookAsserter{ - headerPrefix: customPrefix, + headerPrefix: "x-custom-", expectedSignatures: 1, secrets: []string{"test-secret"}, }, @@ -431,6 +430,79 @@ func TestWebhookPublisher_DisableDefaultHeaders(t *testing.T) { } } +func TestWebhookPublisher_EmptyHeaderPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix *string + want string // expected prefix on headers + }{ + { + name: "nil prefix uses default", + prefix: nil, + want: "x-outpost-", + }, + { + name: "empty string disables prefix", + prefix: new(""), + want: "", + }, + { + name: "whitespace-only disables prefix", + prefix: new(" "), + want: "", + }, + { + name: "custom prefix is applied", + prefix: new("x-custom-"), + want: "x-custom-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + opts := []destwebhook.Option{} + if tt.prefix != nil { + opts = append(opts, destwebhook.WithHeaderPrefix(tt.prefix)) + } + + provider, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil, opts...) + require.NoError(t, err) + + destination := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "http://example.com/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "test-secret", + }), + ) + + publisher, err := provider.CreatePublisher(context.Background(), &destination) + require.NoError(t, err) + + event := testutil.EventFactory.Any( + testutil.EventFactory.WithID("evt_123"), + testutil.EventFactory.WithTopic("user.created"), + testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}), + ) + + req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + + // Verify headers use the expected prefix + assert.Equal(t, "evt_123", req.Header.Get(tt.want+"event-id")) + assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp")) + assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic")) + assert.NotEmpty(t, req.Header.Get(tt.want+"signature")) + }) + } +} + func TestWebhookPublisher_DeliveryMetadata(t *testing.T) { t.Parallel() diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go index 6d1bcabd4..1409de5b3 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard.go @@ -104,11 +104,14 @@ func WithProxyURL(proxyURL string) Option { } } -// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-") -func WithHeaderPrefix(prefix string) Option { +// WithHeaderPrefix sets the prefix for metadata headers (defaults to "webhook-"). +// When prefix is nil, the default prefix is used. +// When prefix is non-nil, its value is used (after trimming whitespace), +// allowing an empty string to disable the prefix entirely. +func WithHeaderPrefix(prefix *string) Option { return func(d *StandardWebhookDestination) { - if prefix != "" { - d.headerPrefix = prefix + if prefix != nil { + d.headerPrefix = strings.TrimSpace(*prefix) } } } diff --git a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go index b5e2f7f1f..9df4ff077 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_config_test.go @@ -126,7 +126,7 @@ func TestNew(t *testing.T) { provider, err := destwebhookstandard.New( testutil.Registry.MetadataLoader(), nil, - destwebhookstandard.WithHeaderPrefix("x-custom-"), + destwebhookstandard.WithHeaderPrefix(new("x-custom-")), ) require.NoError(t, err) assert.NotNil(t, provider) @@ -139,7 +139,7 @@ func TestNew(t *testing.T) { nil, destwebhookstandard.WithUserAgent("test-agent"), destwebhookstandard.WithProxyURL("http://proxy.example.com"), - destwebhookstandard.WithHeaderPrefix("x-outpost-"), + destwebhookstandard.WithHeaderPrefix(new("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 58db6c34c..933b81608 100644 --- a/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go +++ b/internal/destregistry/providers/destwebhookstandard/destwebhookstandard_publish_test.go @@ -384,7 +384,7 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) { provider, err := destwebhookstandard.New( testutil.Registry.MetadataLoader(), nil, - destwebhookstandard.WithHeaderPrefix("x-custom-"), + destwebhookstandard.WithHeaderPrefix(new("x-custom-")), ) require.NoError(t, err) @@ -434,6 +434,79 @@ func TestStandardWebhookPublisher_CustomHeaderPrefix(t *testing.T) { } } +func TestStandardWebhookPublisher_EmptyHeaderPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix *string + want string // expected prefix on headers + }{ + { + name: "nil prefix uses default", + prefix: nil, + want: "webhook-", + }, + { + name: "empty string disables prefix", + prefix: new(""), + want: "", + }, + { + name: "whitespace-only disables prefix", + prefix: new(" "), + want: "", + }, + { + name: "custom prefix is applied", + prefix: new("x-custom-"), + want: "x-custom-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + opts := []destwebhookstandard.Option{} + if tt.prefix != nil { + opts = append(opts, destwebhookstandard.WithHeaderPrefix(tt.prefix)) + } + + provider, err := destwebhookstandard.New(testutil.Registry.MetadataLoader(), nil, opts...) + require.NoError(t, err) + + dest := testutil.DestinationFactory.Any( + testutil.DestinationFactory.WithType("webhook"), + testutil.DestinationFactory.WithConfig(map[string]string{ + "url": "http://example.com/webhook", + }), + testutil.DestinationFactory.WithCredentials(map[string]string{ + "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }), + ) + + publisher, err := provider.CreatePublisher(context.Background(), &dest) + require.NoError(t, err) + + event := testutil.EventFactory.Any( + testutil.EventFactory.WithID("msg_test123"), + testutil.EventFactory.WithTopic("user.created"), + testutil.EventFactory.WithDataMap(map[string]interface{}{"key": "value"}), + ) + + req, err := publisher.(*destwebhookstandard.StandardWebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + + // Verify headers use the expected prefix + assert.Equal(t, "msg_test123", req.Header.Get(tt.want+"id")) + assert.NotEmpty(t, req.Header.Get(tt.want+"timestamp")) + assert.NotEmpty(t, req.Header.Get(tt.want+"signature")) + assert.Equal(t, "user.created", req.Header.Get(tt.want+"topic")) + }) + } +} + func TestStandardWebhookPublisher_CustomHeaders(t *testing.T) { t.Parallel()