diff --git a/.env.example b/.env.example index dac8d6603..e04754450 100644 --- a/.env.example +++ b/.env.example @@ -117,10 +117,12 @@ # Webhook Destination # DESTINATIONS_WEBHOOK_HEADER_PREFIX="x-outpost-" -# DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER=false -# DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER=false -# DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER=false -# DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER=false +# Header name configs are three-state: unset uses the default +key, +# an explicit value pins that exact name, and an empty string disables the header. +# DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME="" +# DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME="" +# DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME="" +# DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME="" # DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE={{.Body}} # DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE=v0={{.Signatures | join ","}} # DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING="hex" diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 8b9b5ffc5..2909801a9 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -3119,14 +3119,12 @@ components: type: string DESTINATIONS_INCLUDE_MILLISECOND_TIMESTAMP: type: string - DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER: - type: string - DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER: - type: string - DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER: - type: string - DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER: + DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME: type: string + description: >- + Complete name of the event ID header. Unset uses the default + "event-id"; an explicit value pins that exact name; an empty + string disables the header. Only applies to "default" mode. DESTINATIONS_WEBHOOK_HEADER_PREFIX: type: string DESTINATIONS_WEBHOOK_MODE: @@ -3139,10 +3137,28 @@ components: type: string DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING: type: string + DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME: + type: string + description: >- + Complete name of the signature header. Unset uses the default + "signature"; an explicit value pins that exact name; an + empty string disables the header. Only applies to "default" mode. DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE: type: string DESTINATIONS_WEBHOOK_SIGNING_SECRET_TEMPLATE: type: string + DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME: + type: string + description: >- + Complete name of the timestamp header. Unset uses the default + "timestamp"; an explicit value pins that exact name; an + empty string disables the header. Only applies to "default" mode. + DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME: + type: string + description: >- + Complete name of the topic header. Unset uses the default + "topic"; an explicit value pins that exact name; an empty + string disables the header. Only applies to "default" mode. HTTP_USER_AGENT: type: string IDGEN_ATTEMPT_PREFIX: diff --git a/docs/content/destinations/webhook.mdoc b/docs/content/destinations/webhook.mdoc index 82afb89c1..8d275b3fd 100644 --- a/docs/content/destinations/webhook.mdoc +++ b/docs/content/destinations/webhook.mdoc @@ -74,7 +74,7 @@ The request body contains the event's `data` field as JSON. The `metadata` field Webhook delivery is **at-least-once** (see [Event delivery & retries](/docs/outpost/features/event-delivery)). Your handler should deduplicate using the **event id** — the same stable `id` you set when [publishing](/docs/outpost/publishing/events) (Outpost may redeliver on retries). -In **default** mode, that id is sent as the system **`event-id`** metadata field. The HTTP header name is **`{header_prefix}{metadata-key}`** with **no extra separator** — the prefix and key are concatenated as-is. So the default prefix `x-outpost-` plus `event-id` yields **`X-Outpost-Event-Id`**; a prefix without a trailing separator (for example `x-acme`) would produce `x-acme` + `event-id` → **`X-Acmeevent-Id`** (Go canonicalizes the wire name). Include a trailing hyphen in the prefix when you want a conventional shape like **`X-Acme-Event-Id`**. Change the prefix with **`DESTINATIONS_WEBHOOK_HEADER_PREFIX`**, or omit this header with **`DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER`**. +In **default** mode, that id is sent as the system **`event-id`** metadata field. The HTTP header name is **`{header_prefix}{metadata-key}`** with **no extra separator** — the prefix and key are concatenated as-is. So the default prefix `x-outpost-` plus `event-id` yields **`X-Outpost-Event-Id`**; a prefix without a trailing separator (for example `x-acme`) would produce `x-acme` + `event-id` → **`X-Acmeevent-Id`** (Go canonicalizes the wire name). Include a trailing hyphen in the prefix when you want a conventional shape like **`X-Acme-Event-Id`**. Change the prefix with **`DESTINATIONS_WEBHOOK_HEADER_PREFIX`**, pin the full header name with **`DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME`**, or omit this header by setting that variable to an empty string. In **Standard Webhooks** mode, the same value is sent as the **`webhook-id`** header (default prefix `webhook-`, so typically **`Webhook-Id`**) per the [Standard Webhooks](https://www.standardwebhooks.com/) specification. @@ -271,7 +271,7 @@ Set `DESTINATIONS_WEBHOOK_PROXY_URL` to your proxy URL (basic auth supported). S {% tabs tabGroup="deployment" %} {% tab label="Managed" %} -Configure webhook operator behavior using these keys in the Config API or in [Hookdeck Destinations settings](https://dashboard.hookdeck.com/settings/project/destinations): `DESTINATIONS_WEBHOOK_HEADER_PREFIX`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER`, `DESTINATIONS_WEBHOOK_MODE`, `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM`, `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING`, `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE`, `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE`, and `DESTINATIONS_WEBHOOK_MAX_RESPONSE_BODY_BYTES`. +Configure webhook operator behavior using these keys in the Config API or in [Hookdeck Destinations settings](https://dashboard.hookdeck.com/settings/project/destinations): `DESTINATIONS_WEBHOOK_HEADER_PREFIX`, `DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME`, `DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME`, `DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME`, `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME`, `DESTINATIONS_WEBHOOK_MODE`, `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM`, `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING`, `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE`, `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE`, and `DESTINATIONS_WEBHOOK_MAX_RESPONSE_BODY_BYTES`. {% /tab %} {% tab label="Self-Hosted" %} ### Header Settings @@ -279,10 +279,18 @@ Configure webhook operator behavior using these keys in the Config API or in [Ho | Variable | Default | Description | |----------|---------|-------------| | `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | `x-outpost-` / `webhook-` | Prefix for system webhook headers (event id, topic, timestamp, signature). Unless overridden, defaults to **`x-outpost-`** when `DESTINATIONS_WEBHOOK_MODE` is `default` and **`webhook-`** when `standard`. | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | `false` | Disable the event ID header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | `false` | Disable the timestamp header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | `false` | Disable the topic header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | `false` | Disable the signature header | +| `DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME` | — | Complete name of the event ID header. Unset uses the default `event-id`; an explicit value pins that exact name; an empty string disables the header. | +| `DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME` | — | Complete name of the timestamp header. Unset uses the default `timestamp`; an explicit value pins that exact name; an empty string disables the header. | +| `DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME` | — | Complete name of the topic header. Unset uses the default `topic`; an explicit value pins that exact name; an empty string disables the header. | +| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME` | — | Complete name of the signature header. Unset uses the default `signature`; an explicit value pins that exact name; an empty string disables the header. | + +{% callout type="warning" %} +The `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_*_HEADER` flags are deprecated and will be removed in a future version. Disable a header by setting its corresponding `*_HEADER_NAME` variable to an empty string instead. +{% /callout %} + +{% callout type="info" %} +Header names are matched case-insensitively. Outpost rejects a configuration where two system headers resolve to the same name (for example pinning both event id and topic to `x-acme`). Also avoid pinning a system header to a name you also set via per-destination `custom_headers` — the last write wins and the result is not guaranteed. +{% /callout %} ### Signature Settings diff --git a/docs/content/guides/migrate-to-outpost.mdoc b/docs/content/guides/migrate-to-outpost.mdoc index 3a191d7fa..baf2bd396 100644 --- a/docs/content/guides/migrate-to-outpost.mdoc +++ b/docs/content/guides/migrate-to-outpost.mdoc @@ -123,10 +123,10 @@ For walkthroughs, see the [curl](/docs/outpost/quickstarts/hookdeck-outpost-curl | YAML | Environment Variable | Default Value | | ---------------------------------- | ------------------------------------------------------- | -------------------------------- | | `header_prefix` | `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | `x-outpost` | -| `disable_default_event_id_header` | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | `false` | -| `disable_default_signature_header` | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | `false` | -| `disable_default_timestamp_header` | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | `false` | -| `disable_default_topic_header` | `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | `false` | +| `event_id_header_name` | `DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME` | — | +| `signature_header_name` | `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME` | — | +| `timestamp_header_name` | `DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME` | — | +| `topic_header_name` | `DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME` | — | | `signature_content_template` | `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | `{{.Body}}` | | `signature_header_template` | `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | `v0={{.Signatures \| join ","}}` | | `signature_encoding` | `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | `hex` | diff --git a/docs/content/self-hosting/configuration.mdoc b/docs/content/self-hosting/configuration.mdoc index ed7ad5839..b0d1317ee 100644 --- a/docs/content/self-hosting/configuration.mdoc +++ b/docs/content/self-hosting/configuration.mdoc @@ -116,14 +116,18 @@ Choose one for event log persistence: |----------|---------|-------------| | `DESTINATIONS_WEBHOOK_MODE` | `default` | Set to `standard` for Standard Webhooks compliance | | `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | `x-outpost-` / `webhook-` | Prefix for system webhook headers (event id, topic, timestamp, signature). Unless overridden, defaults to **`x-outpost-`** when `DESTINATIONS_WEBHOOK_MODE` is `default` and **`webhook-`** when `standard`. | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | `false` | Disable the event ID header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | `false` | Disable the timestamp header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | `false` | Disable the topic header | -| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | `false` | Disable the signature header | +| `DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME` | — | Complete name of the event ID header. Unset uses the default `event-id`; an explicit value pins that exact name; an empty string disables the header. Only applies to `default` mode. | +| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME` | — | Complete name of the signature header. Unset uses the default `signature`; an explicit value pins that exact name; an empty string disables the header. Only applies to `default` mode. | +| `DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME` | — | Complete name of the timestamp header. Unset uses the default `timestamp`; an explicit value pins that exact name; an empty string disables the header. Only applies to `default` mode. | +| `DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME` | — | Complete name of the topic header. Unset uses the default `topic`; an explicit value pins that exact name; an empty string disables the header. Only applies to `default` mode. | | `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | `hmac-sha256` | Signature algorithm | | `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | `hex` | Encoding: `hex` or `base64` | | `DESTINATIONS_WEBHOOK_MAX_RESPONSE_BODY_BYTES` | `131072` (128 KiB) | Max bytes of a destination response body stored on the delivery attempt. Larger responses are replaced with a placeholder so the attempt log stays under the event queue's per-message size limit. Set to `0` to disable the cap. | +{% callout type="warning" %} +The `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER`, and `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` flags are deprecated and will be removed in a future version. Disable a header by setting its corresponding `*_HEADER_NAME` variable to an empty string instead. A set `*_HEADER_NAME` always takes precedence over the matching deprecated flag. +{% /callout %} + ## Observability | Variable | Description | diff --git a/internal/app/app.go b/internal/app/app.go index 7c1d73d49..bb6ba34e8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -66,6 +66,10 @@ func (a *App) PreRun(ctx context.Context) (err error) { a.logger.Info("starting outpost", a.config.LogConfigurationSummary()...) + for _, warning := range a.config.DeprecationWarnings() { + a.logger.Warn(warning) + } + if err := a.configureIDGenerators(); err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 5ce40e95d..25c4357c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,6 +291,7 @@ func (c *Config) parseEnvVariables(osInterface OSInterface) error { } c.captureEmptyAlertEnv(osInterface) + c.captureEmptyWebhookHeaderEnv(osInterface) return nil } @@ -309,6 +310,23 @@ func (c *Config) captureEmptyAlertEnv(osInterface OSInterface) { } } +// captureEmptyWebhookHeaderEnv honors "an empty env var disables this webhook +// header". Same caarlos0/env gap as captureEmptyAlertEnv: a present-but-empty +// env var never reaches the OptionalString unmarshaler, so we detect it via +// LookupEnv and set the empty (disabled) state explicitly. +func (c *Config) captureEmptyWebhookHeaderEnv(osInterface OSInterface) { + for envVar, field := range map[string]*OptionalString{ + "DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME": &c.Destinations.Webhook.EventIDHeaderName, + "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": &c.Destinations.Webhook.SignatureHeaderName, + "DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME": &c.Destinations.Webhook.TimestampHeaderName, + "DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME": &c.Destinations.Webhook.TopicHeaderName, + } { + if v, ok := osInterface.LookupEnv(envVar); ok && v == "" { + *field = NewOptionalString("") + } + } +} + func (c *Config) normalizeTopics() { if len(c.Topics) == 0 { return @@ -557,6 +575,12 @@ func resolveAlertCount(raw OptionalString, defaultValue, min int) (resolvedAlert return resolvedAlertCount{enabled: true, value: n}, nil } +// DeprecationWarnings returns human-readable warnings for deprecated config +// options that are actively in use, so callers can surface them at startup. +func (c *Config) DeprecationWarnings() []string { + return c.Destinations.Webhook.deprecationWarnings() +} + // ConfigFilePath returns the path of the config file that was used func (c *Config) ConfigFilePath() string { return c.configPath diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dd91bce3c..35332ba28 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,7 +6,9 @@ import ( "testing" "github.com/hookdeck/outpost/internal/config" + destregistrydefault "github.com/hookdeck/outpost/internal/destregistry/providers" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type mockOS struct { @@ -455,6 +457,157 @@ destinations: } } +func TestDestinationWebhookHeaderNames(t *testing.T) { + // want is the resolved SignatureHeader directive after parsing. + tests := []struct { + name string + files map[string][]byte + envVars map[string]string + want destregistrydefault.WebhookHeaderConfig + }{ + { + name: "unset uses default", + files: map[string][]byte{}, + envVars: map[string]string{}, + want: destregistrydefault.WebhookHeaderConfig{}, + }, + { + name: "env value pins name", + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": "x-acme-digest"}, + want: destregistrydefault.WebhookHeaderConfig{Name: "x-acme-digest"}, + }, + { + name: "empty env disables", + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": ""}, + want: destregistrydefault.WebhookHeaderConfig{Disabled: true}, + }, + { + name: "yaml value pins name", + files: map[string][]byte{ + "config.yaml": []byte(` +destinations: + webhook: + signature_header_name: x-globex-sig +`), + }, + envVars: map[string]string{"CONFIG": "config.yaml"}, + want: destregistrydefault.WebhookHeaderConfig{Name: "x-globex-sig"}, + }, + { + name: "empty yaml disables", + files: map[string][]byte{ + "config.yaml": []byte(` +destinations: + webhook: + signature_header_name: "" +`), + }, + envVars: map[string]string{"CONFIG": "config.yaml"}, + want: destregistrydefault.WebhookHeaderConfig{Disabled: true}, + }, + { + name: "whitespace-only env disables", + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": " "}, + want: destregistrydefault.WebhookHeaderConfig{Disabled: true}, + }, + { + name: "deprecated disable flag disables", + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER": "true"}, + want: destregistrydefault.WebhookHeaderConfig{Disabled: true}, + }, + { + name: "new config wins over deprecated flag", + files: map[string][]byte{}, + envVars: map[string]string{ + "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER": "true", + "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": "x-acme-digest", + }, + want: destregistrydefault.WebhookHeaderConfig{Name: "x-acme-digest"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOS := &mockOS{ + files: tt.files, + envVars: tt.envVars, + } + + cfg, err := config.ParseWithoutValidation(config.Flags{}, mockOS) + assert.NoError(t, err) + + opts := cfg.Destinations.ToConfig(cfg) + require.NotNil(t, opts.Webhook) + assert.Equal(t, tt.want, opts.Webhook.SignatureHeader) + }) + } +} + +func TestDestinationWebhookHeaderNamesAllHeaders(t *testing.T) { + // Each of the four headers resolves independently through the same rule. + mockOS := &mockOS{ + files: map[string][]byte{}, + envVars: map[string]string{ + "DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME": "x-acme-event", + "DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME": "", + // signature left unset -> default; topic disabled via deprecated flag + "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER": "true", + }, + } + + cfg, err := config.ParseWithoutValidation(config.Flags{}, mockOS) + require.NoError(t, err) + + opts := cfg.Destinations.ToConfig(cfg) + require.NotNil(t, opts.Webhook) + assert.Equal(t, destregistrydefault.WebhookHeaderConfig{Name: "x-acme-event"}, opts.Webhook.EventIDHeader) + assert.Equal(t, destregistrydefault.WebhookHeaderConfig{Disabled: true}, opts.Webhook.TimestampHeader) + assert.Equal(t, destregistrydefault.WebhookHeaderConfig{}, opts.Webhook.SignatureHeader) + assert.Equal(t, destregistrydefault.WebhookHeaderConfig{Disabled: true}, opts.Webhook.TopicHeader) +} + +func TestDestinationWebhookDeprecationWarnings(t *testing.T) { + t.Run("warns when deprecated flag is true", func(t *testing.T) { + mockOS := &mockOS{ + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER": "true"}, + } + cfg, err := config.ParseWithoutValidation(config.Flags{}, mockOS) + require.NoError(t, err) + warnings := cfg.DeprecationWarnings() + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER") + assert.Contains(t, warnings[0], "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME") + }) + + t.Run("silent when flag is false", func(t *testing.T) { + mockOS := &mockOS{ + files: map[string][]byte{}, + envVars: map[string]string{"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER": "false"}, + } + cfg, err := config.ParseWithoutValidation(config.Flags{}, mockOS) + require.NoError(t, err) + assert.Empty(t, cfg.DeprecationWarnings()) + }) + + t.Run("warns even when new config overrides", func(t *testing.T) { + mockOS := &mockOS{ + files: map[string][]byte{}, + envVars: map[string]string{ + "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER": "true", + "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME": "x-acme-digest", + }, + } + cfg, err := config.ParseWithoutValidation(config.Flags{}, mockOS) + require.NoError(t, err) + assert.Len(t, cfg.DeprecationWarnings(), 1) + }) +} + func TestConfigFilePath(t *testing.T) { tests := []struct { name string diff --git a/internal/config/destinations.go b/internal/config/destinations.go index 39688e0d6..13fe1bee6 100644 --- a/internal/config/destinations.go +++ b/internal/config/destinations.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "strings" destregistrydefault "github.com/hookdeck/outpost/internal/destregistry/providers" "github.com/hookdeck/outpost/internal/version" @@ -50,19 +51,33 @@ 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 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"` - 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"` - SigningSecretTemplate string `yaml:"signing_secret_template" env:"DESTINATIONS_WEBHOOK_SIGNING_SECRET_TEMPLATE" desc:"Go template for generating webhook signing secrets. Available variables: {{.RandomHex}} (64-char hex), {{.RandomBase64}} (base64-encoded), {{.RandomAlphanumeric}} (32-char alphanumeric). Defaults to 'whsec_{{.RandomHex}}'. Only applies to 'default' mode." required:"N"` - MaxResponseBodyBytes int `yaml:"max_response_body_bytes" env:"DESTINATIONS_WEBHOOK_MAX_RESPONSE_BODY_BYTES" desc:"Maximum size in bytes of a destination's response body stored on the delivery attempt. Responses larger than this are replaced with a placeholder so the attempt log stays under the event queue's per-message size limit (oversized log messages fail to publish and retry indefinitely). Default: 131072 (128 KiB). Set to 0 to disable the cap." required:"N"` + 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. Set to whitespace (e.g. ' ') to disable the prefix entirely." required:"N"` + + // Header name configs. Each is three-state: unset uses the default + // '' + key, an explicit value pins that exact header name, and an + // empty string disables the header entirely. Only applies to 'default' mode. + EventIDHeaderName OptionalString `yaml:"event_id_header_name" env:"DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME" desc:"Complete name of the event ID header. Unset uses the default 'event-id'; an explicit value pins that exact name; an empty string disables the header. Only applies to 'default' mode." required:"N"` + SignatureHeaderName OptionalString `yaml:"signature_header_name" env:"DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME" desc:"Complete name of the signature header. Unset uses the default 'signature'; an explicit value pins that exact name; an empty string disables the header. Only applies to 'default' mode." required:"N"` + TimestampHeaderName OptionalString `yaml:"timestamp_header_name" env:"DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME" desc:"Complete name of the timestamp header. Unset uses the default 'timestamp'; an explicit value pins that exact name; an empty string disables the header. Only applies to 'default' mode." required:"N"` + TopicHeaderName OptionalString `yaml:"topic_header_name" env:"DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME" desc:"Complete name of the topic header. Unset uses the default 'topic'; an explicit value pins that exact name; an empty string disables the header. Only applies to 'default' mode." required:"N"` + + // Deprecated: replaced by the *_HEADER_NAME configs above. Setting one of + // these to true still disables the corresponding header (an empty + // *_HEADER_NAME is the replacement) but logs a deprecation warning. A new + // *_HEADER_NAME config always takes precedence over the matching flag. + DisableDefaultEventIDHeader bool `yaml:"disable_default_event_id_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER" desc:"Deprecated: set DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME to an empty string to disable the event ID header instead. Only applies to 'default' mode." required:"N"` + DisableDefaultSignatureHeader bool `yaml:"disable_default_signature_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER" desc:"Deprecated: set DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME to an empty string to disable the signature header instead. Only applies to 'default' mode." required:"N"` + DisableDefaultTimestampHeader bool `yaml:"disable_default_timestamp_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER" desc:"Deprecated: set DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME to an empty string to disable the timestamp header instead. Only applies to 'default' mode." required:"N"` + DisableDefaultTopicHeader bool `yaml:"disable_default_topic_header" env:"DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER" desc:"Deprecated: set DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME to an empty string to disable the topic header instead. 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"` + SigningSecretTemplate string `yaml:"signing_secret_template" env:"DESTINATIONS_WEBHOOK_SIGNING_SECRET_TEMPLATE" desc:"Go template for generating webhook signing secrets. Available variables: {{.RandomHex}} (64-char hex), {{.RandomBase64}} (base64-encoded), {{.RandomAlphanumeric}} (32-char alphanumeric). Defaults to 'whsec_{{.RandomHex}}'. Only applies to 'default' mode." required:"N"` + MaxResponseBodyBytes int `yaml:"max_response_body_bytes" env:"DESTINATIONS_WEBHOOK_MAX_RESPONSE_BODY_BYTES" desc:"Maximum size in bytes of a destination's response body stored on the delivery attempt. Responses larger than this are replaced with a placeholder so the attempt log stays under the event queue's per-message size limit (oversized log messages fail to publish and retry indefinitely). Default: 131072 (128 KiB). Set to 0 to disable the cap." required:"N"` } // toConfig converts WebhookConfig to the provider config - private since it's only used internally @@ -83,20 +98,67 @@ func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookCo } return &destregistrydefault.DestWebhookConfig{ - Mode: c.Mode, - ProxyURL: c.ProxyURL, - HeaderPrefix: headerPrefix, - DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader, - DisableDefaultSignatureHeader: c.DisableDefaultSignatureHeader, - DisableDefaultTimestampHeader: c.DisableDefaultTimestampHeader, - DisableDefaultTopicHeader: c.DisableDefaultTopicHeader, - SignatureContentTemplate: c.SignatureContentTemplate, - SignatureHeaderTemplate: c.SignatureHeaderTemplate, - SignatureEncoding: c.SignatureEncoding, - SignatureAlgorithm: c.SignatureAlgorithm, - SigningSecretTemplate: c.SigningSecretTemplate, - MaxResponseBodyBytes: c.MaxResponseBodyBytes, + Mode: c.Mode, + ProxyURL: c.ProxyURL, + HeaderPrefix: headerPrefix, + EventIDHeader: resolveWebhookHeaderName(c.EventIDHeaderName, c.DisableDefaultEventIDHeader), + SignatureHeader: resolveWebhookHeaderName(c.SignatureHeaderName, c.DisableDefaultSignatureHeader), + TimestampHeader: resolveWebhookHeaderName(c.TimestampHeaderName, c.DisableDefaultTimestampHeader), + TopicHeader: resolveWebhookHeaderName(c.TopicHeaderName, c.DisableDefaultTopicHeader), + SignatureContentTemplate: c.SignatureContentTemplate, + SignatureHeaderTemplate: c.SignatureHeaderTemplate, + SignatureEncoding: c.SignatureEncoding, + SignatureAlgorithm: c.SignatureAlgorithm, + SigningSecretTemplate: c.SigningSecretTemplate, + MaxResponseBodyBytes: c.MaxResponseBodyBytes, + } +} + +// resolveWebhookHeaderName applies the three-state rule for a webhook header +// name, folding in the deprecated DISABLE_* flag for backward compatibility: +// - name set to a value -> {Name: value} (pin exact name) +// - name set to "" or whitespace-only -> {Disabled: true} (disable) +// - name unset, flag true -> {Disabled: true} (deprecated disable) +// - name unset, flag false -> {} (provider builds +key) +// +// A set name always wins over the deprecated flag. +func resolveWebhookHeaderName(name OptionalString, deprecatedDisable bool) destregistrydefault.WebhookHeaderConfig { + if value, set := name.Get(); set { + if strings.TrimSpace(value) == "" { + return destregistrydefault.WebhookHeaderConfig{Disabled: true} + } + return destregistrydefault.WebhookHeaderConfig{Name: value} + } + if deprecatedDisable { + return destregistrydefault.WebhookHeaderConfig{Disabled: true} + } + return destregistrydefault.WebhookHeaderConfig{} +} + +// deprecationWarnings returns a message for each deprecated DISABLE_* flag that +// is actively set to true (the case that changes behavior). A set *_HEADER_NAME +// config still takes precedence over the flag, but the warning is emitted +// regardless so operators migrate off the deprecated var. +func (c *DestinationWebhookConfig) deprecationWarnings() []string { + var warnings []string + for _, d := range []struct { + enabled bool + oldEnv string + newEnv string + }{ + {c.DisableDefaultEventIDHeader, "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER", "DESTINATIONS_WEBHOOK_EVENT_ID_HEADER_NAME"}, + {c.DisableDefaultSignatureHeader, "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER", "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME"}, + {c.DisableDefaultTimestampHeader, "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER", "DESTINATIONS_WEBHOOK_TIMESTAMP_HEADER_NAME"}, + {c.DisableDefaultTopicHeader, "DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER", "DESTINATIONS_WEBHOOK_TOPIC_HEADER_NAME"}, + } { + if d.enabled { + warnings = append(warnings, fmt.Sprintf( + "%s is deprecated and will be removed in a future version. Set %s to an empty string to disable the header instead.", + d.oldEnv, d.newEnv, + )) + } } + return warnings } // AWS Kinesis configuration diff --git a/internal/config/logging.go b/internal/config/logging.go index 116c3f0f6..94c625630 100644 --- a/internal/config/logging.go +++ b/internal/config/logging.go @@ -3,10 +3,24 @@ package config import ( "strings" + destregistrydefault "github.com/hookdeck/outpost/internal/destregistry/providers" "github.com/hookdeck/outpost/internal/version" "go.uber.org/zap" ) +// webhookHeaderSummary renders a resolved webhook header directive for the +// startup log: "disabled", "" (prefix + key), or the pinned name. +func webhookHeaderSummary(h destregistrydefault.WebhookHeaderConfig) string { + switch { + case h.Disabled: + return "disabled" + case h.Name == "": + return "" + default: + return h.Name + } +} + // LogConfigurationSummary returns zap fields with configuration summary, masking sensitive data // // ⚠️ IMPORTANT: When adding new configuration fields, you MUST update this function @@ -24,6 +38,11 @@ func (c *Config) LogConfigurationSummary() []zap.Field { // is ignored — config is already validated by the time this runs. alertSettings, _ := c.Alert.ToConfig() + // Resolve the webhook config so the summary reflects the effective header + // directives after folding in the three-state name configs and deprecated + // DISABLE_* flags. + webhookCfg := c.Destinations.Webhook.toConfig() + fields := []zap.Field{ // General zap.String("service", c.Service), @@ -108,6 +127,13 @@ func (c *Config) LogConfigurationSummary() []zap.Field { // Retention zap.Int("clickhouse_log_retention_ttl_days", c.ClickHouseLogRetentionTTLDays), + + // Destinations - Webhook (effective header directives after resolving the + // three-state name configs and deprecated DISABLE_* flags) + zap.String("destinations_webhook_event_id_header", webhookHeaderSummary(webhookCfg.EventIDHeader)), + zap.String("destinations_webhook_signature_header", webhookHeaderSummary(webhookCfg.SignatureHeader)), + zap.String("destinations_webhook_timestamp_header", webhookHeaderSummary(webhookCfg.TimestampHeader)), + zap.String("destinations_webhook_topic_header", webhookHeaderSummary(webhookCfg.TopicHeader)), } // Add MQ-specific fields based on type diff --git a/internal/destregistry/providers/default.go b/internal/destregistry/providers/default.go index b5e09e6e0..5388c6831 100644 --- a/internal/destregistry/providers/default.go +++ b/internal/destregistry/providers/default.go @@ -14,20 +14,29 @@ import ( "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhookstandard" ) +// WebhookHeaderConfig is the resolved directive for a single webhook system +// header. The config layer collapses the three-state name config (and the +// deprecated DISABLE_* flag) into this: an empty Name with Disabled false means +// "use the default '' + key". +type WebhookHeaderConfig struct { + Name string + Disabled bool +} + type DestWebhookConfig struct { - Mode string - ProxyURL string - HeaderPrefix string - DisableDefaultEventIDHeader bool - DisableDefaultSignatureHeader bool - DisableDefaultTimestampHeader bool - DisableDefaultTopicHeader bool - SignatureContentTemplate string - SignatureHeaderTemplate string - SignatureEncoding string - SignatureAlgorithm string - SigningSecretTemplate string - MaxResponseBodyBytes int + Mode string + ProxyURL string + HeaderPrefix string + EventIDHeader WebhookHeaderConfig + SignatureHeader WebhookHeaderConfig + TimestampHeader WebhookHeaderConfig + TopicHeader WebhookHeaderConfig + SignatureContentTemplate string + SignatureHeaderTemplate string + SignatureEncoding string + SignatureAlgorithm string + SigningSecretTemplate string + MaxResponseBodyBytes int } type DestAWSKinesisConfig struct { @@ -76,10 +85,10 @@ func RegisterDefault(registry destregistry.Registry, opts RegisterDefaultDestina 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.WithEventIDHeader(opts.Webhook.EventIDHeader.Name, opts.Webhook.EventIDHeader.Disabled), + destwebhook.WithSignatureHeader(opts.Webhook.SignatureHeader.Name, opts.Webhook.SignatureHeader.Disabled), + destwebhook.WithTimestampHeader(opts.Webhook.TimestampHeader.Name, opts.Webhook.TimestampHeader.Disabled), + destwebhook.WithTopicHeader(opts.Webhook.TopicHeader.Name, opts.Webhook.TopicHeader.Disabled), destwebhook.WithSignatureContentTemplate(opts.Webhook.SignatureContentTemplate), destwebhook.WithSignatureHeaderTemplate(opts.Webhook.SignatureHeaderTemplate), destwebhook.WithSignatureEncoding(opts.Webhook.SignatureEncoding), diff --git a/internal/destregistry/providers/destwebhook/destwebhook.go b/internal/destregistry/providers/destwebhook/destwebhook.go index 908bdeb80..92a94809c 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook.go +++ b/internal/destregistry/providers/destwebhook/destwebhook.go @@ -84,6 +84,13 @@ func ValidateCustomHeaders(headers map[string]string) error { return nil } +// headerConfig is the resolved directive for a single system header: an empty +// name with disabled=false means "use the default '' + key". +type headerConfig struct { + name string + disabled bool +} + type WebhookDestination struct { *destregistry.BaseProvider headerPrefix string @@ -91,10 +98,10 @@ type WebhookDestination struct { proxyURL string signatureContentTemplate string signatureHeaderTemplate string - disableEventIDHeader bool - disableSignatureHeader bool - disableTimestampHeader bool - disableTopicHeader bool + eventIDHeader headerConfig + signatureHeader headerConfig + timestampHeader headerConfig + topicHeader headerConfig encoding string algorithm string rawSigningSecretTemplate string @@ -154,28 +161,39 @@ func WithMaxResponseBodyBytes(maxBytes int) Option { } } -// Add these options after the existing Option definitions -func WithDisableDefaultEventIDHeader(disable bool) Option { +// WithEventIDHeader sets the event ID header directive. A non-empty name pins +// the exact header name (bypassing "event-id"); disabled omits the +// header. The name is trimmed of whitespace. +func WithEventIDHeader(name string, disabled bool) Option { return func(w *WebhookDestination) { - w.disableEventIDHeader = disable + w.eventIDHeader = headerConfig{name: strings.TrimSpace(name), disabled: disabled} } } -func WithDisableDefaultSignatureHeader(disable bool) Option { +// WithSignatureHeader sets the signature header directive. A non-empty name pins +// the exact header name (bypassing "signature"); disabled omits the +// header. The name is trimmed of whitespace. +func WithSignatureHeader(name string, disabled bool) Option { return func(w *WebhookDestination) { - w.disableSignatureHeader = disable + w.signatureHeader = headerConfig{name: strings.TrimSpace(name), disabled: disabled} } } -func WithDisableDefaultTimestampHeader(disable bool) Option { +// WithTimestampHeader sets the timestamp header directive. A non-empty name pins +// the exact header name (bypassing "timestamp"); disabled omits the +// header. The name is trimmed of whitespace. +func WithTimestampHeader(name string, disabled bool) Option { return func(w *WebhookDestination) { - w.disableTimestampHeader = disable + w.timestampHeader = headerConfig{name: strings.TrimSpace(name), disabled: disabled} } } -func WithDisableDefaultTopicHeader(disable bool) Option { +// WithTopicHeader sets the topic header directive. A non-empty name pins the +// exact header name (bypassing "topic"); disabled omits the header. The +// name is trimmed of whitespace. +func WithTopicHeader(name string, disabled bool) Option { return func(w *WebhookDestination) { - w.disableTopicHeader = disable + w.topicHeader = headerConfig{name: strings.TrimSpace(name), disabled: disabled} } } @@ -247,6 +265,41 @@ func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePu return nil, fmt.Errorf("signing secret template is required") } + // Validate the four system headers: reject invalid/reserved pinned names, and + // reject collisions between the effective names (pinned name, or "" + + // key) of two enabled headers. A collision would make emission order-dependent + // since Format writes headers from a map, so whichever iterated last would + // silently win. + seenNames := map[string]string{} + for _, h := range []struct { + label string + key string + cfg headerConfig + }{ + {"event ID", "event-id", destination.eventIDHeader}, + {"signature", "signature", destination.signatureHeader}, + {"timestamp", "timestamp", destination.timestampHeader}, + {"topic", "topic", destination.topicHeader}, + } { + if h.cfg.disabled { + continue + } + if h.cfg.name != "" { + if !headerNameRegex.MatchString(h.cfg.name) { + return nil, fmt.Errorf("invalid %s header name %q: must match %s", h.label, h.cfg.name, headerNameRegex.String()) + } + if reservedHeaders[strings.ToLower(h.cfg.name)] { + return nil, fmt.Errorf("invalid %s header name %q: reserved header", h.label, h.cfg.name) + } + } + // HTTP header names are case-insensitive, so compare lowercased. + effective := strings.ToLower(resolveHeaderName(h.cfg, destination.headerPrefix, h.key)) + if other, ok := seenNames[effective]; ok { + return nil, fmt.Errorf("%s and %s headers resolve to the same name %q; pin distinct names or disable one", other, h.label, effective) + } + seenNames[effective] = h.label + } + // Parse signing secret template — fail on invalid syntax tmpl, err := template.New("signing_secret").Funcs(sprig.TxtFuncMap()).Parse(destination.rawSigningSecretTemplate) if err != nil { @@ -361,18 +414,18 @@ func (d *WebhookDestination) CreatePublisher(ctx context.Context, destination *m } 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, - maxResponseBodyBytes: d.maxResponseBodyBytes, + BasePublisher: d.BaseProvider.NewPublisher(destregistry.WithDeliveryMetadata(destination.DeliveryMetadata)), + httpClient: httpClient, + url: config.URL, + headerPrefix: d.headerPrefix, + eventIDHeader: d.eventIDHeader, + signatureHeader: d.signatureHeader, + timestampHeader: d.timestampHeader, + topicHeader: d.topicHeader, + secrets: secrets, + sm: sm, + customHeaders: config.CustomHeaders, + maxResponseBodyBytes: d.maxResponseBodyBytes, }, nil } @@ -643,17 +696,17 @@ func (d *WebhookDestination) Preprocess(newDestination *models.Destination, orig 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 - maxResponseBodyBytes int + httpClient *http.Client + url string + headerPrefix string + eventIDHeader headerConfig + signatureHeader headerConfig + timestampHeader headerConfig + topicHeader headerConfig + secrets []WebhookSecret + sm *SignatureManager + customHeaders map[string]string + maxResponseBodyBytes int } func (p *WebhookPublisher) Close() error { @@ -696,29 +749,19 @@ func (p *WebhookPublisher) Format(ctx context.Context, event *models.Event) (*ht // Get merged metadata (system + event metadata) using BasePublisher metadata := p.BasePublisher.MakeMetadata(event, now) - // Add headers from metadata, respecting disable flags + // Add headers from metadata. Known system headers respect their configured + // directive (disabled -> skip, custom name -> pin, default -> +key); + // unknown metadata keys always use the prefix. 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 - } + name, ok := p.resolveMetadataHeaderName(key) + if !ok { + continue } - // Add the header with the appropriate prefix - req.Header.Set(p.headerPrefix+key, value) + req.Header.Set(name, value) } - // Add signature header if not disabled - if !p.disableSignatureHeader { + // Add signature header unless disabled + if !p.signatureHeader.disabled { signatureHeader := p.sm.GenerateSignatureHeader(SignaturePayload{ EventID: event.ID, Topic: event.Topic, @@ -726,13 +769,43 @@ func (p *WebhookPublisher) Format(ctx context.Context, event *models.Event) (*ht Body: string(rawBody), }) if signatureHeader != "" { - req.Header.Set(p.headerPrefix+"signature", signatureHeader) + req.Header.Set(resolveHeaderName(p.signatureHeader, p.headerPrefix, "signature"), signatureHeader) } } return req, nil } +// resolveMetadataHeaderName returns the header name to use for a metadata key +// and whether it should be emitted. Known system keys (event-id, timestamp, +// topic) follow their configured directive; unknown keys always use the prefix. +func (p *WebhookPublisher) resolveMetadataHeaderName(key string) (string, bool) { + var cfg headerConfig + switch key { + case "event-id": + cfg = p.eventIDHeader + case "timestamp": + cfg = p.timestampHeader + case "topic": + cfg = p.topicHeader + default: + return p.headerPrefix + key, true + } + if cfg.disabled { + return "", false + } + return resolveHeaderName(cfg, p.headerPrefix, key), true +} + +// resolveHeaderName returns the pinned custom name when set, otherwise the +// default "" + key. +func resolveHeaderName(cfg headerConfig, prefix, key string) string { + if cfg.name != "" { + return cfg.name + } + return prefix + key +} + // generateSignatureSecret creates a cryptographically secure random secret using the configured template. // The default template produces a 64-character hex string (32 random bytes). func (d *WebhookDestination) generateSignatureSecret() (string, error) { diff --git a/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go b/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go index 33d996875..104f2b05a 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go +++ b/internal/destregistry/providers/destwebhook/destwebhook_publish_test.go @@ -350,25 +350,25 @@ func TestWebhookPublisher_DisableDefaultHeaders(t *testing.T) { }{ { name: "disable event id header", - options: []destwebhook.Option{destwebhook.WithDisableDefaultEventIDHeader(true)}, + options: []destwebhook.Option{destwebhook.WithEventIDHeader("", true)}, expectedHeader: "x-outpost-event-id", shouldExist: false, }, { name: "disable signature header", - options: []destwebhook.Option{destwebhook.WithDisableDefaultSignatureHeader(true)}, + options: []destwebhook.Option{destwebhook.WithSignatureHeader("", true)}, expectedHeader: "x-outpost-signature", shouldExist: false, }, { name: "disable timestamp header", - options: []destwebhook.Option{destwebhook.WithDisableDefaultTimestampHeader(true)}, + options: []destwebhook.Option{destwebhook.WithTimestampHeader("", true)}, expectedHeader: "x-outpost-timestamp", shouldExist: false, }, { name: "disable topic header", - options: []destwebhook.Option{destwebhook.WithDisableDefaultTopicHeader(true)}, + options: []destwebhook.Option{destwebhook.WithTopicHeader("", true)}, expectedHeader: "x-outpost-topic", shouldExist: false, }, @@ -475,6 +475,179 @@ func TestWebhookPublisher_EmptyHeaderPrefix(t *testing.T) { } } +func TestWebhookPublisher_SignatureHeaderName(t *testing.T) { + t.Parallel() + + t.Run("unset uses signature", func(t *testing.T) { + t.Parallel() + + provider := NewTestProvider(t, destwebhook.WithHeaderPrefix("x-outpost-")) + + 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.WithDataMap(map[string]interface{}{"key": "value"}), + ) + + req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + + assert.NotEmpty(t, req.Header.Get("x-outpost-signature")) + }) + + t.Run("set uses exact name regardless of prefix", func(t *testing.T) { + t.Parallel() + + provider := NewTestProvider(t, + destwebhook.WithHeaderPrefix("x-outpost-"), + destwebhook.WithSignatureHeader("x-acme-digest", false), + ) + + 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.WithDataMap(map[string]interface{}{"key": "value"}), + ) + + req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + + assert.NotEmpty(t, req.Header.Get("x-acme-digest")) + assert.Empty(t, req.Header.Get("x-outpost-signature"), "default signature header must be absent") + }) + + t.Run("disabled signature header suppresses even when name configured", func(t *testing.T) { + t.Parallel() + + provider := NewTestProvider(t, + destwebhook.WithSignatureHeader("x-acme-digest", true), + ) + + 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.WithDataMap(map[string]interface{}{"key": "value"}), + ) + + req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + + assert.Empty(t, req.Header.Get("x-acme-digest")) + assert.Empty(t, req.Header.Get("x-outpost-signature")) + }) + + t.Run("two destinations sign under their own configured names", func(t *testing.T) { + t.Parallel() + + makeReq := func(name string) *http.Request { + provider := NewTestProvider(t, destwebhook.WithSignatureHeader(name, false)) + 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.WithDataMap(map[string]interface{}{"key": "value"}), + ) + req, err := publisher.(*destwebhook.WebhookPublisher).Format(context.Background(), &event) + require.NoError(t, err) + return req + } + + reqA := makeReq("x-acme-digest") + reqB := makeReq("x-globex-sig") + + assert.NotEmpty(t, reqA.Header.Get("x-acme-digest")) + assert.Empty(t, reqA.Header.Get("x-globex-sig")) + + assert.NotEmpty(t, reqB.Header.Get("x-globex-sig")) + assert.Empty(t, reqB.Header.Get("x-acme-digest")) + }) +} + +func TestWebhookPublisher_CustomHeaderNames(t *testing.T) { + t.Parallel() + + // A custom name pins the exact header for each system header, bypassing the + // "" + key construction. + provider := NewTestProvider(t, + destwebhook.WithHeaderPrefix("x-outpost-"), + destwebhook.WithEventIDHeader("x-acme-event", false), + destwebhook.WithTimestampHeader("x-acme-time", false), + destwebhook.WithTopicHeader("x-acme-topic", false), + ) + + 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) + + assert.Equal(t, "evt_123", req.Header.Get("x-acme-event")) + assert.NotEmpty(t, req.Header.Get("x-acme-time")) + assert.Equal(t, "user.created", req.Header.Get("x-acme-topic")) + + // Default-prefixed names must be absent when a custom name is pinned. + assert.Empty(t, req.Header.Get("x-outpost-event-id")) + assert.Empty(t, req.Header.Get("x-outpost-timestamp")) + assert.Empty(t, req.Header.Get("x-outpost-topic")) +} + func TestWebhookPublisher_DeliveryMetadata(t *testing.T) { t.Parallel() @@ -663,8 +836,8 @@ func TestWebhookPublisher_CustomHeaders(t *testing.T) { t.Parallel() provider := NewTestProvider(t, - destwebhook.WithDisableDefaultTimestampHeader(true), - destwebhook.WithDisableDefaultTopicHeader(true), + destwebhook.WithTimestampHeader("", true), + destwebhook.WithTopicHeader("", true), ) destination := testutil.DestinationFactory.Any( diff --git a/internal/destregistry/providers/destwebhook/destwebhook_validate_test.go b/internal/destregistry/providers/destwebhook/destwebhook_validate_test.go index 860715d35..00d39b93a 100644 --- a/internal/destregistry/providers/destwebhook/destwebhook_validate_test.go +++ b/internal/destregistry/providers/destwebhook/destwebhook_validate_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/hookdeck/outpost/internal/destregistry" + "github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook" "github.com/hookdeck/outpost/internal/util/maputil" "github.com/hookdeck/outpost/internal/util/testutil" "github.com/stretchr/testify/assert" @@ -336,6 +337,121 @@ func TestWebhookDestination_ValidateCustomHeaders(t *testing.T) { }) } +func TestWebhookDestination_HeaderNameValidation(t *testing.T) { + t.Parallel() + + // Each header name flows through the same regex/reserved-header validation. + headerOptions := map[string]func(string) destwebhook.Option{ + "event-id": func(n string) destwebhook.Option { return destwebhook.WithEventIDHeader(n, false) }, + "signature": func(n string) destwebhook.Option { return destwebhook.WithSignatureHeader(n, false) }, + "timestamp": func(n string) destwebhook.Option { return destwebhook.WithTimestampHeader(n, false) }, + "topic": func(n string) destwebhook.Option { return destwebhook.WithTopicHeader(n, false) }, + } + + newProvider := func(headerOpt destwebhook.Option) error { + _, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil, + destwebhook.WithHeaderPrefix(destwebhook.DefaultHeaderPrefix), + destwebhook.WithSignatureContentTemplate(destwebhook.DefaultSignatureContentTmpl), + destwebhook.WithSignatureHeaderTemplate(destwebhook.DefaultSignatureHeaderTmpl), + destwebhook.WithSignatureEncoding(destwebhook.DefaultEncoding), + destwebhook.WithSignatureAlgorithm(destwebhook.DefaultAlgorithm), + destwebhook.WithSigningSecretTemplate(destwebhook.DefaultSigningSecretTmpl), + headerOpt, + ) + return err + } + + for header, opt := range headerOptions { + opt := opt + t.Run(header, func(t *testing.T) { + t.Parallel() + + t.Run("accepts valid names", func(t *testing.T) { + t.Parallel() + for _, name := range []string{"x-acme-digest", "X-Custom-Signature", "signature123", "X_Custom_Sig"} { + assert.NoError(t, newProvider(opt(name)), "name %q should be valid", name) + } + }) + + t.Run("rejects invalid characters", func(t *testing.T) { + t.Parallel() + for _, name := range []string{"has space", "has:colon", "-leading-dash", "_leading_underscore"} { + assert.Error(t, newProvider(opt(name)), "name %q should be rejected", name) + } + }) + + t.Run("rejects reserved headers", func(t *testing.T) { + t.Parallel() + for _, name := range []string{"Content-Type", "content-type", "Host", "User-Agent"} { + assert.Error(t, newProvider(opt(name)), "reserved name %q should be rejected", name) + } + }) + }) + } +} + +func TestWebhookDestination_HeaderNameCollision(t *testing.T) { + t.Parallel() + + newProvider := func(opts ...destwebhook.Option) error { + base := []destwebhook.Option{ + destwebhook.WithHeaderPrefix(destwebhook.DefaultHeaderPrefix), + destwebhook.WithSignatureContentTemplate(destwebhook.DefaultSignatureContentTmpl), + destwebhook.WithSignatureHeaderTemplate(destwebhook.DefaultSignatureHeaderTmpl), + destwebhook.WithSignatureEncoding(destwebhook.DefaultEncoding), + destwebhook.WithSignatureAlgorithm(destwebhook.DefaultAlgorithm), + destwebhook.WithSigningSecretTemplate(destwebhook.DefaultSigningSecretTmpl), + } + _, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil, append(base, opts...)...) + return err + } + + t.Run("rejects two pinned names that collide", func(t *testing.T) { + t.Parallel() + err := newProvider( + destwebhook.WithEventIDHeader("x-acme", false), + destwebhook.WithTopicHeader("x-acme", false), + ) + assert.Error(t, err) + }) + + t.Run("collision is case-insensitive", func(t *testing.T) { + t.Parallel() + err := newProvider( + destwebhook.WithEventIDHeader("X-Acme", false), + destwebhook.WithTopicHeader("x-acme", false), + ) + assert.Error(t, err) + }) + + t.Run("rejects pinned name colliding with another default", func(t *testing.T) { + t.Parallel() + // event-id pinned to the default topic header name collides with topic's default. + err := newProvider( + destwebhook.WithEventIDHeader(destwebhook.DefaultHeaderPrefix+"topic", false), + ) + assert.Error(t, err) + }) + + t.Run("no collision when the duplicate header is disabled", func(t *testing.T) { + t.Parallel() + err := newProvider( + destwebhook.WithEventIDHeader("x-acme", false), + destwebhook.WithTopicHeader("x-acme", true), + ) + assert.NoError(t, err) + }) + + t.Run("distinct pinned names are accepted", func(t *testing.T) { + t.Parallel() + err := newProvider( + destwebhook.WithEventIDHeader("x-acme-event", false), + destwebhook.WithTopicHeader("x-acme-topic", false), + ) + assert.NoError(t, err) + }) +} + func TestWebhookDestination_ComputeTarget(t *testing.T) { t.Parallel()