Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/pages/references/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Global configurations are provided through env variables or a YAML file. ConfigM
| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | Go template for constructing the content to be signed for webhook requests. Only applies to 'default' mode. | `{{.Body}}` | No |
| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | Encoding for the signature (e.g., 'hex', 'base64'). Only applies to 'default' mode. | `hex` | No |
| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | Go template for the value of the signature header. Only applies to 'default' mode. | `v0={{.Signatures \| join ","}}` | No |
| `DESTINATIONS_WEBHOOK_SIGNING_SECRET_TEMPLATE` | Go template for generating webhook signing secrets. Available variables: \{\{.RandomHex\}\} (64-char hex), \{\{.RandomBase64\}\} (base64-encoded), \{\{.RandomAlphanumeric\}\} (32-char alphanumeric). Defaults to '\{\{.RandomHex\}\}'. Only applies to 'default' mode. | `nil` | No |
| `DESTINATIONS_WEBHOOK_SIGNING_SECRET_TEMPLATE` | 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. | `nil` | No |
| `DISABLE_TELEMETRY` | Global flag to disable all telemetry (anonymous usage statistics to Hookdeck and error reporting to Sentry). If true, overrides 'telemetry.disabled'. | `false` | No |
| `GCP_PUBSUB_DELIVERY_SUBSCRIPTION` | Name of the GCP Pub/Sub subscription for delivery events. | `outpost-delivery-sub` | No |
| `GCP_PUBSUB_DELIVERY_TOPIC` | Name of the GCP Pub/Sub topic for delivery events. | `outpost-delivery` | No |
Expand Down Expand Up @@ -260,7 +260,7 @@ destinations:
# Go template for the value of the signature header. Only applies to 'default' mode.
signature_header_template: "v0={{.Signatures | join \",\"}}"

# Go template for generating webhook signing secrets. Available variables: {{.RandomHex}} (64-char hex), {{.RandomBase64}} (base64-encoded), {{.RandomAlphanumeric}} (32-char alphanumeric). Defaults to '{{.RandomHex}}'. Only applies to 'default' mode.
# 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.
signing_secret_template: ""


Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,12 @@ func (c *Config) InitDefaults() {
c.Destinations = DestinationsConfig{
MetadataPath: "config/outpost/destinations",
Webhook: DestinationWebhookConfig{
Mode: "default",
SignatureContentTemplate: "{{.Body}}",
SignatureHeaderTemplate: "v0={{.Signatures | join \",\"}}",
SignatureEncoding: "hex",
SignatureAlgorithm: "hmac-sha256",
SigningSecretTemplate: "whsec_{{.RandomHex}}",
},
AWSKinesis: DestinationAWSKinesisConfig{
MetadataInPayload: true,
Expand Down
29 changes: 15 additions & 14 deletions internal/config/destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,28 @@ type DestinationWebhookConfig struct {
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 '{{.RandomHex}}'. 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"`
}

// toConfig converts WebhookConfig to the provider config - private since it's only used internally
// Config guarantees all required values are set via setDefaults()
func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookConfig {
mode := c.Mode
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
// HeaderPrefix: config provides mode-specific defaults
// - user sets "" → config applies default based on mode ("x-outpost-" or "webhook-")
// - user sets " " → whitespace passes through → provider trims to "" (disabled)
// - user sets explicit value → passes through as-is
headerPrefix := c.HeaderPrefix
if headerPrefix == "" {
// Apply mode-specific default only when truly empty (not whitespace)
if c.Mode == "standard" {
headerPrefix = "webhook-"
} else {
headerPrefix = "x-outpost-"
}
}

return &destregistrydefault.DestWebhookConfig{
Mode: mode,
Mode: c.Mode,
ProxyURL: c.ProxyURL,
HeaderPrefix: headerPrefix,
DisableDefaultEventIDHeader: c.DisableDefaultEventIDHeader,
Expand Down
35 changes: 9 additions & 26 deletions internal/destinationmockserver/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -170,30 +169,13 @@ func verifySignature(secret string, payload []byte, signature string, algorithm
encoding = "hex"
}

// Parse timestamp and signature from header
// Header format: t=1234567890,v0=signature1,signature2
var timestamp time.Time
var signatures []string

parts := strings.Split(signature, ",")
for i, part := range parts {
if strings.HasPrefix(part, "t=") {
ts, err := strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
if err != nil {
return false
}
timestamp = time.Unix(ts, 0)
} else if strings.HasPrefix(part, "v0=") {
// First v0 part contains the prefix
signatures = append(signatures, strings.TrimPrefix(part, "v0="))
} else if i > 0 && strings.HasPrefix(parts[i-1], "v0=") {
// Additional signatures after v0= don't have the prefix
signatures = append(signatures, part)
}
// Parse signature from header
// Header format: v0=signature1,signature2
if !strings.HasPrefix(signature, "v0=") {
return false
}

// If we couldn't parse timestamp or no signatures found, verification fails
if timestamp.IsZero() || len(signatures) == 0 {
signatures := strings.Split(strings.TrimPrefix(signature, "v0="), ",")
if len(signatures) == 0 {
return false
}

Expand All @@ -208,12 +190,13 @@ func verifySignature(secret string, payload []byte, signature string, algorithm
secrets,
destwebhook.WithEncoder(destwebhook.GetEncoder(encoding)),
destwebhook.WithAlgorithm(destwebhook.GetAlgorithm(algorithm)),
destwebhook.WithSignatureFormatter(destwebhook.NewSignatureFormatter(destwebhook.DefaultSignatureContentTmpl)),
destwebhook.WithHeaderFormatter(destwebhook.NewHeaderFormatter(destwebhook.DefaultSignatureHeaderTmpl)),
)

for _, sig := range signatures {
if sm.VerifySignature(sig, secret, destwebhook.SignaturePayload{
Body: string(payload),
Timestamp: timestamp,
Body: string(payload),
}) {
return true
}
Expand Down
2 changes: 1 addition & 1 deletion internal/destregistry/providers/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
type DestWebhookConfig struct {
Mode string
ProxyURL string
HeaderPrefix *string
HeaderPrefix string
DisableDefaultEventIDHeader bool
DisableDefaultSignatureHeader bool
DisableDefaultTimestampHeader bool
Expand Down
32 changes: 8 additions & 24 deletions internal/destregistry/providers/destwebhook/assert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"strconv"
"strings"

testsuite "github.com/hookdeck/outpost/internal/destregistry/testing"
Expand All @@ -13,40 +12,25 @@ import (
)

// Helper function to assert signature format
// Expected format: "v0={sig1,sig2,...}"
func assertSignatureFormat(t testsuite.TestingT, signatureHeader string, expectedSignatureCount int) {
t.Helper()

parts := strings.SplitN(signatureHeader, ",", 2)
require.True(t, len(parts) >= 2, "signature header should have timestamp and signature parts")

// Verify timestamp format
assert.True(t, strings.HasPrefix(parts[0], "t="), "should start with t=")
timestampStr := strings.TrimPrefix(parts[0], "t=")
_, err := strconv.ParseInt(timestampStr, 10, 64)
require.NoError(t, err, "timestamp should be a valid integer")

// Verify signature format and count
assert.True(t, strings.HasPrefix(parts[1], "v0="), "should start with v0=")
signatures := strings.Split(strings.TrimPrefix(parts[1], "v0="), ",")
require.True(t, strings.HasPrefix(signatureHeader, "v0="), "signature header should start with v0=")
signatures := strings.Split(strings.TrimPrefix(signatureHeader, "v0="), ",")
assert.Len(t, signatures, expectedSignatureCount, "should have exact number of signatures")
}

// Helper function to assert valid signature
// Signed content is the raw body (no timestamp prefix)
func assertValidSignature(t testsuite.TestingT, secret string, rawBody []byte, signatureHeader string) {
t.Helper()

// Parse "t={timestamp},v0={signature1,signature2}" format
parts := strings.SplitN(signatureHeader, ",", 2) // Split only on first comma
require.True(t, len(parts) >= 2, "signature header should have timestamp and signature parts")

timestampStr := strings.TrimPrefix(parts[0], "t=")
signatures := strings.Split(strings.TrimPrefix(parts[1], "v0="), ",")

timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
require.NoError(t, err, "timestamp should be a valid integer")
require.True(t, strings.HasPrefix(signatureHeader, "v0="), "signature header should start with v0=")
signatures := strings.Split(strings.TrimPrefix(signatureHeader, "v0="), ",")

// Reconstruct the signed content
signedContent := fmt.Sprintf("%d.%s", timestamp, rawBody)
// Signed content is just the body
signedContent := string(rawBody)

// Generate HMAC-SHA256
mac := hmac.New(sha256.New, []byte(secret))
Expand Down
53 changes: 32 additions & 21 deletions internal/destregistry/providers/destwebhook/destwebhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ import (
)

const (
DefaultEncoding = "hex"
DefaultAlgorithm = "hmac-sha256"
DefaultEncoding = "hex"
DefaultAlgorithm = "hmac-sha256"
DefaultHeaderPrefix = "x-outpost-"
DefaultSignatureContentTmpl = "{{.Body}}"
DefaultSignatureHeaderTmpl = "v0={{.Signatures | join \",\"}}"
DefaultSigningSecretTmpl = "whsec_{{.RandomHex}}"
)

// Reserved headers that cannot be set via custom_headers
Expand Down Expand Up @@ -119,15 +123,12 @@ 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.
// 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 {
// WithHeaderPrefix sets the prefix for webhook request headers.
// The prefix is trimmed of whitespace. An empty string disables the prefix entirely.
// Config is responsible for providing the appropriate default ("x-outpost-" or "webhook-").
func WithHeaderPrefix(prefix string) Option {
return func(w *WebhookDestination) {
if prefix != nil {
w.headerPrefix = strings.TrimSpace(*prefix)
}
w.headerPrefix = strings.TrimSpace(prefix)
}
}

Expand Down Expand Up @@ -199,8 +200,6 @@ func WithSigningSecretTemplate(templateStr string) Option {
}
}

const defaultSigningSecretTemplate = `{{.RandomHex}}`

// signingSecretTemplateData holds the variables available in signing secret templates.
type signingSecretTemplateData struct {
RandomHex string
Expand All @@ -215,22 +214,34 @@ func New(loader metadata.MetadataLoader, basePublisherOpts []destregistry.BasePu
}
destination := &WebhookDestination{
BaseProvider: base,
headerPrefix: "x-outpost-",
encoding: DefaultEncoding,
algorithm: DefaultAlgorithm,
}
for _, opt := range opts {
opt(destination)
}

// Parse signing secret template — fail on invalid syntax
templateStr := destination.rawSigningSecretTemplate
if templateStr == "" {
templateStr = defaultSigningSecretTemplate
// Validate all required configuration is provided
// Config is responsible for setting defaults - provider requires explicit values
// Note: headerPrefix may be empty (after trimming) to disable prefix entirely
if destination.encoding == "" {
return nil, fmt.Errorf("signature encoding is required")
}
if destination.algorithm == "" {
return nil, fmt.Errorf("signature algorithm is required")
}
if destination.signatureContentTemplate == "" {
return nil, fmt.Errorf("signature content template is required")
}
tmpl, err := template.New("signing_secret").Funcs(sprig.TxtFuncMap()).Parse(templateStr)
if destination.signatureHeaderTemplate == "" {
return nil, fmt.Errorf("signature header template is required")
}
if destination.rawSigningSecretTemplate == "" {
return nil, fmt.Errorf("signing secret template is required")
}

// Parse signing secret template — fail on invalid syntax
tmpl, err := template.New("signing_secret").Funcs(sprig.TxtFuncMap()).Parse(destination.rawSigningSecretTemplate)
if err != nil {
return nil, fmt.Errorf("invalid signing secret template %q: %w", templateStr, err)
return nil, fmt.Errorf("invalid signing secret template %q: %w", destination.rawSigningSecretTemplate, err)
}
destination.signingSecretTemplate = tmpl

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ func TestGetAlgorithm(t *testing.T) {
func TestWebhookDestination_CustomHeadersConfig(t *testing.T) {
t.Parallel()

webhookDestination, err := destwebhook.New(testutil.Registry.MetadataLoader(), nil)
assert.NoError(t, err)
webhookDestination := NewTestProvider(t)

t.Run("should parse config with valid custom_headers", func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -168,32 +167,53 @@ func TestWebhookDestination_SignatureOptions(t *testing.T) {
wantAlgorithm string
}{
{
name: "default values",
opts: []destwebhook.Option{},
name: "default values",
opts: []destwebhook.Option{
destwebhook.WithHeaderPrefix("x-outpost-"),
destwebhook.WithSignatureContentTemplate("{{.Body}}"),
destwebhook.WithSignatureHeaderTemplate("v0={{.Signatures | join \",\"}}"),
destwebhook.WithSignatureEncoding(destwebhook.DefaultEncoding),
destwebhook.WithSignatureAlgorithm(destwebhook.DefaultAlgorithm),
destwebhook.WithSigningSecretTemplate("whsec_{{.RandomHex}}"),
},
wantEncoding: destwebhook.DefaultEncoding,
wantAlgorithm: destwebhook.DefaultAlgorithm,
},
{
name: "custom encoding",
opts: []destwebhook.Option{
destwebhook.WithHeaderPrefix("x-outpost-"),
destwebhook.WithSignatureContentTemplate("{{.Body}}"),
destwebhook.WithSignatureHeaderTemplate("v0={{.Signatures | join \",\"}}"),
destwebhook.WithSignatureEncoding("base64"),
destwebhook.WithSignatureAlgorithm(destwebhook.DefaultAlgorithm),
destwebhook.WithSigningSecretTemplate("whsec_{{.RandomHex}}"),
},
wantEncoding: "base64",
wantAlgorithm: destwebhook.DefaultAlgorithm,
},
{
name: "custom algorithm",
opts: []destwebhook.Option{
destwebhook.WithHeaderPrefix("x-outpost-"),
destwebhook.WithSignatureContentTemplate("{{.Body}}"),
destwebhook.WithSignatureHeaderTemplate("v0={{.Signatures | join \",\"}}"),
destwebhook.WithSignatureEncoding(destwebhook.DefaultEncoding),
destwebhook.WithSignatureAlgorithm("hmac-sha1"),
destwebhook.WithSigningSecretTemplate("whsec_{{.RandomHex}}"),
},
wantEncoding: destwebhook.DefaultEncoding,
wantAlgorithm: "hmac-sha1",
},
{
name: "custom encoding and algorithm",
opts: []destwebhook.Option{
destwebhook.WithHeaderPrefix("x-outpost-"),
destwebhook.WithSignatureContentTemplate("{{.Body}}"),
destwebhook.WithSignatureHeaderTemplate("v0={{.Signatures | join \",\"}}"),
destwebhook.WithSignatureEncoding("base64"),
destwebhook.WithSignatureAlgorithm("hmac-sha1"),
destwebhook.WithSigningSecretTemplate("whsec_{{.RandomHex}}"),
},
wantEncoding: "base64",
wantAlgorithm: "hmac-sha1",
Expand Down
Loading
Loading