Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/content/self-hosting/configuration.mdoc
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Choose one for event log persistence:
| `DESTINATIONS_PROXY_URL` | — | HTTP `CONNECT` forward proxy for destination connections, e.g. `http://user:pass@proxy:8080`. Multiple whitespace-separated URLs are tunneled in order. Applies to webhook, RabbitMQ and Kafka destinations; other destination types connect directly. The deprecated `DESTINATIONS_WEBHOOK_PROXY_URL`, when set, takes precedence for webhooks; there is no way to proxy RabbitMQ and Kafka without also proxying webhooks. See [Webhook Forward Proxy](/docs/outpost/self-hosting/guides/webhook-proxy#other-destination-types). |
| `DESTINATIONS_METADATA_PATH` | — | Optional. Filesystem path to a directory of [custom destination metadata](https://github.com/hookdeck/outpost/tree/main/internal/destregistry/metadata/providers) (per-type `metadata.json` and `instructions.md`). Non-core fields such as `label`, `description`, `icon`, and `instructions` can be customized; `config_fields` and `credential_fields` cannot be overridden. |
| `DESTINATIONS_AWS_EVENTBRIDGE_SOURCE` | `outpost` | `Source` set on every event published by [AWS EventBridge](/docs/outpost/destinations/aws-eventbridge) destinations. Must not be empty or start with `aws.`. |
| `DESTINATIONS_PRIVATE_ADDRESS_ALLOWLIST` | — | Comma-separated IP addresses and CIDR ranges that webhooks may still reach while `DESTINATIONS_WEBHOOK_BLOCK_PRIVATE_ADDRESSES` is on, e.g. `10.0.0.0/8,192.168.1.5`. `localhost` stands for `127.0.0.0/8` and `::1`. Host names, `/0` ranges and ranges with host bits set (`10.0.0.1/8`) fail startup, also while blocking is off. |

## Webhook Behavior

Expand All @@ -176,6 +177,7 @@ Choose one for event log persistence:
| `DESTINATIONS_WEBHOOK_COMPAT_SIGNATURE_SECRET_PREFIX` | — | Prefix stripped from the destination secret before decoding for the compat signature |
| `DESTINATIONS_WEBHOOK_COMPAT_HEADERS` | — | Extra headers sent with the compat signature, as comma-separated `name=template` pairs (`{{.EventID}}`, `{{.Topic}}`, `{{.Timestamp}}`). `\,` is a literal comma. |
| `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. |
| `DESTINATIONS_WEBHOOK_BLOCK_PRIVATE_ADDRESSES` | `false` | If `true`, webhooks connect only to publicly routable addresses (per the IANA special-purpose address registries) or to addresses in `DESTINATIONS_PRIVATE_ADDRESS_ALLOWLIST`. Destination URLs are checked on create and update (validation error) and on every connection; a blocked delivery fails with the code `address_not_allowed`. Host names are resolved as given, without DNS search domains. With a forward proxy (`DESTINATIONS_PROXY_URL`), Outpost checks the target before each request but the proxy resolves it again; configure the proxy to refuse private addresses too. `HTTP_PROXY` and `HTTPS_PROXY` are ignored for webhooks while this is on. Internationalized host names must be given in punycode. |

{% 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.
Expand Down
86 changes: 86 additions & 0 deletions internal/apirouter/destination_private_address_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package apirouter_test

import (
"encoding/json"
"net/http"
"testing"

"github.com/hookdeck/outpost/internal/destregistry"
destregistrydefault "github.com/hookdeck/outpost/internal/destregistry/providers"
"github.com/hookdeck/outpost/internal/destregistry/providers/destwebhook"
"github.com/hookdeck/outpost/internal/util/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func webhookRegistry(t *testing.T, block bool, allowlist ...string) destregistry.Registry {
t.Helper()
reg := destregistry.NewRegistry(&destregistry.Config{}, testutil.CreateTestLogger(t))
err := destregistrydefault.RegisterDefault(reg, destregistrydefault.RegisterDefaultDestinationOptions{
Webhook: &destregistrydefault.DestWebhookConfig{
HeaderPrefix: destwebhook.DefaultHeaderPrefix,
SignatureContentTemplate: destwebhook.DefaultSignatureContentTmpl,
SignatureHeaderTemplate: destwebhook.DefaultSignatureHeaderTmpl,
SignatureEncoding: destwebhook.DefaultEncoding,
SignatureAlgorithm: destwebhook.DefaultAlgorithm,
SigningSecretTemplate: destwebhook.DefaultSigningSecretTmpl,
BlockPrivateAddresses: block,
},
PrivateAddressAllowlist: allowlist,
AWSEventBridge: &destregistrydefault.DestAWSEventBridgeConfig{Source: "outpost"},
})
require.NoError(t, err)
return reg
}

func TestDestinationPrivateAddresses(t *testing.T) {
post := func(h *apiTest, id, url string) (int, string) {
req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{
"id": id,
"type": "webhook",
"topics": []string{"user.created"},
"config": map[string]string{"url": url},
})
resp := h.do(h.withAPIKey(req))
return resp.Code, resp.Body.String()
}

t.Run("blocked", func(t *testing.T) {
h := newAPITest(t, withDestRegistry(webhookRegistry(t, true)))
h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1")))

code, body := post(h, "d1", "http://10.0.0.1/hook")
require.Equal(t, http.StatusUnprocessableEntity, code, body)
var got struct {
Message string `json:"message"`
Data []string `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(body), &got))
assert.Equal(t, "validation error", got.Message)
assert.Equal(t, []string{"config.url must resolve to a public address"}, got.Data)

code, body = post(h, "d2", "https://8.8.8.8/hook")
require.Equal(t, http.StatusCreated, code, body)

patch := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d2", map[string]any{
"config": map[string]string{"url": "http://127.0.0.1/hook"},
})
resp := h.do(h.withAPIKey(patch))
assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
assert.Contains(t, resp.Body.String(), "config.url must resolve to a public address")
})

t.Run("allowlisted", func(t *testing.T) {
h := newAPITest(t, withDestRegistry(webhookRegistry(t, true, "10.0.0.0/8")))
h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1")))
code, body := post(h, "d1", "http://10.0.0.1/hook")
assert.Equal(t, http.StatusCreated, code, body)
})

t.Run("switch off", func(t *testing.T) {
h := newAPITest(t, withDestRegistry(webhookRegistry(t, false)))
h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1")))
code, body := post(h, "d1", "http://10.0.0.1/hook")
assert.Equal(t, http.StatusCreated, code, body)
})
}
2 changes: 2 additions & 0 deletions internal/apirouter/errorhandler_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func formatValidationError(field, tag, param string) string {
return fmt.Sprintf("%s must be at most %s characters", field, param)
case "email":
return fmt.Sprintf("%s must be a valid email address", field)
case "address_not_allowed":
return fmt.Sprintf("%s must resolve to a public address", field)
case "url":
return fmt.Sprintf("%s must be a valid URL", field)
case "oneof":
Expand Down
33 changes: 17 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,22 +127,23 @@ type Config struct {
}

var (
ErrMismatchedServiceType = errors.New("config validation error: service type mismatch")
ErrInvalidServiceType = errors.New("config validation error: invalid service type")
ErrMissingRedis = errors.New("config validation error: redis configuration is required")
ErrMissingLogStorage = errors.New("config validation error: log storage must be provided")
ErrMissingMQs = errors.New("config validation error: message queue configuration is required")
ErrMissingAESSecret = errors.New("config validation error: AES encryption secret is required")
ErrInvalidPortalProxyURL = errors.New("config validation error: invalid portal proxy url")
ErrInvalidWebhookProxyURL = errors.New("config validation error: invalid webhook proxy url")
ErrInvalidAWSEventBridgeSource = errors.New("config validation error: invalid aws eventbridge source")
ErrInvalidDestinationsProxyURL = errors.New("config validation error: invalid destinations proxy url")
ErrInvalidPublishProxyURL = errors.New("config validation error: invalid publish proxy url")
ErrInvalidDeploymentID = errors.New("config validation error: deployment_id must contain only alphanumeric characters, hyphens, and underscores (max 64 characters)")
ErrInvalidRedisPoolSize = errors.New("config validation error: redis pool_size must be >= 0")
ErrInvalidPublishMaxRedeliveries = errors.New("config validation error: publish_max_redeliveries must be >= -1")
ErrInvalidSupervisorLimit = errors.New("config validation error: invalid supervisor limit")
ErrInvalidSupervisorWorker = errors.New("config validation error: invalid supervisor restart worker")
ErrMismatchedServiceType = errors.New("config validation error: service type mismatch")
ErrInvalidServiceType = errors.New("config validation error: invalid service type")
ErrMissingRedis = errors.New("config validation error: redis configuration is required")
ErrMissingLogStorage = errors.New("config validation error: log storage must be provided")
ErrMissingMQs = errors.New("config validation error: message queue configuration is required")
ErrMissingAESSecret = errors.New("config validation error: AES encryption secret is required")
ErrInvalidPortalProxyURL = errors.New("config validation error: invalid portal proxy url")
ErrInvalidWebhookProxyURL = errors.New("config validation error: invalid webhook proxy url")
ErrInvalidAWSEventBridgeSource = errors.New("config validation error: invalid aws eventbridge source")
ErrInvalidDestinationsProxyURL = errors.New("config validation error: invalid destinations proxy url")
ErrInvalidPrivateAddressAllowlist = errors.New("config validation error: invalid private address allowlist")
ErrInvalidPublishProxyURL = errors.New("config validation error: invalid publish proxy url")
ErrInvalidDeploymentID = errors.New("config validation error: deployment_id must contain only alphanumeric characters, hyphens, and underscores (max 64 characters)")
ErrInvalidRedisPoolSize = errors.New("config validation error: redis pool_size must be >= 0")
ErrInvalidPublishMaxRedeliveries = errors.New("config validation error: publish_max_redeliveries must be >= -1")
ErrInvalidSupervisorLimit = errors.New("config validation error: invalid supervisor limit")
ErrInvalidSupervisorWorker = errors.New("config validation error: invalid supervisor restart worker")
)

func (c *Config) InitDefaults() {
Expand Down
5 changes: 5 additions & 0 deletions internal/config/destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type DestinationsConfig struct {
MetadataPath string `yaml:"metadata_path" env:"DESTINATIONS_METADATA_PATH" desc:"Path to the directory containing custom destination type definitions." required:"N"`
IncludeMillisecondTimestamp bool `yaml:"include_millisecond_timestamp" env:"DESTINATIONS_INCLUDE_MILLISECOND_TIMESTAMP" desc:"If true, includes a 'timestamp-ms' field with millisecond precision in destination metadata. Useful for load testing and debugging." required:"N"`
ProxyURL string `yaml:"proxy_url" env:"DESTINATIONS_PROXY_URL" desc:"HTTP CONNECT forward proxy for destination connections, e.g. 'http://user:pass@proxy:10000'. Multiple whitespace-separated URLs are tunneled in order, nearest first. Applies to webhook, RabbitMQ and Kafka destinations; other types connect directly. DESTINATIONS_WEBHOOK_PROXY_URL, when set, takes precedence for webhooks; webhooks can't opt out while this is set." required:"N"`
PrivateAddressAllowlist []string `yaml:"private_address_allowlist" env:"DESTINATIONS_PRIVATE_ADDRESS_ALLOWLIST" envSeparator:"," desc:"Comma-separated IP addresses and CIDR ranges that webhooks may still reach while DESTINATIONS_WEBHOOK_BLOCK_PRIVATE_ADDRESSES is on (in YAML, a list). 'localhost' stands for 127.0.0.0/8 and ::1. Host names, /0 ranges and ranges with host bits set fail startup, also while blocking is off." required:"N"`
Webhook DestinationWebhookConfig `yaml:"webhook" desc:"Configuration specific to webhook destinations."`
AWSKinesis DestinationAWSKinesisConfig `yaml:"aws_kinesis" desc:"Configuration specific to AWS Kinesis destinations."`
AWSEventBridge DestinationAWSEventBridgeConfig `yaml:"aws_eventbridge" desc:"Configuration specific to AWS EventBridge destinations."`
Expand All @@ -35,6 +36,7 @@ func (c *DestinationsConfig) ToConfig(cfg *Config) destregistrydefault.RegisterD
IncludeMillisecondTimestamp: c.IncludeMillisecondTimestamp,
ProxyURL: c.ProxyURL,
Webhook: webhook,
PrivateAddressAllowlist: c.PrivateAddressAllowlist,
AWSKinesis: c.AWSKinesis.toConfig(),
AWSEventBridge: c.AWSEventBridge.toConfig(),
DeliveryMaxConcurrency: cfg.DeliveryMaxConcurrency,
Expand Down Expand Up @@ -94,6 +96,8 @@ type DestinationWebhookConfig struct {
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"`

BlockPrivateAddresses bool `yaml:"block_private_addresses" env:"DESTINATIONS_WEBHOOK_BLOCK_PRIVATE_ADDRESSES" desc:"If true, webhooks connect only to publicly routable addresses (IANA special-purpose registries) or to addresses in DESTINATIONS_PRIVATE_ADDRESS_ALLOWLIST. URLs are checked on create/update and on every connection. Host names are resolved as given, without DNS search domains. With a forward proxy, Outpost checks the target before each request but the proxy resolves it again; configure the proxy to refuse private addresses too. HTTP_PROXY/HTTPS_PROXY are ignored for webhooks while this is on. Defaults to false." required:"N"`

Compat DestinationWebhookCompatConfig `yaml:"compat" desc:"A second signature sent alongside the primary one, so receivers verifying an older scheme keep working while they migrate."`
}

Expand Down Expand Up @@ -180,6 +184,7 @@ func (c *DestinationWebhookConfig) toConfig() *destregistrydefault.DestWebhookCo
SignatureAlgorithm: c.SignatureAlgorithm,
SigningSecretTemplate: c.SigningSecretTemplate,
MaxResponseBodyBytes: c.MaxResponseBodyBytes,
BlockPrivateAddresses: c.BlockPrivateAddresses,
}
if c.Mode == "standard" {
applyStandardWebhooks(cfg)
Expand Down
2 changes: 2 additions & 0 deletions internal/config/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ func (c *Config) LogConfigurationSummary() []zap.Field {
zap.Bool("publish_proxy_url_configured", strings.TrimSpace(c.PublishMQ.ProxyURL) != ""),
zap.Bool("destinations_proxy_url_configured", strings.TrimSpace(c.Destinations.ProxyURL) != ""),
zap.Bool("destinations_webhook_proxied", strings.TrimSpace(webhookCfg.ProxyURL) != ""),
zap.Bool("destinations_webhook_block_private_addresses", webhookCfg.BlockPrivateAddresses),
zap.Int("destinations_private_address_allowlist_entries", len(c.Destinations.PrivateAddressAllowlist)),

// Destinations - Webhook (effective header directives after resolving the
// three-state name configs and deprecated DISABLE_* flags)
Expand Down
79 changes: 79 additions & 0 deletions internal/config/private_address_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package config_test

import (
"testing"

"github.com/hookdeck/outpost/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDestinationsPrivateAddresses(t *testing.T) {
t.Run("off and empty by default", func(t *testing.T) {
cfg, err := config.ParseWithoutValidation(config.Flags{}, &mockOS{files: map[string][]byte{}, envVars: map[string]string{}})
require.NoError(t, err)
opts := cfg.Destinations.ToConfig(cfg)
assert.False(t, opts.Webhook.BlockPrivateAddresses)
assert.Empty(t, opts.PrivateAddressAllowlist)
})

t.Run("env", func(t *testing.T) {
for _, mode := range []string{"default", "standard"} {
cfg, err := config.ParseWithoutValidation(config.Flags{}, &mockOS{files: map[string][]byte{}, envVars: map[string]string{
"DESTINATIONS_WEBHOOK_MODE": mode,
"DESTINATIONS_WEBHOOK_BLOCK_PRIVATE_ADDRESSES": "true",
"DESTINATIONS_PRIVATE_ADDRESS_ALLOWLIST": "10.0.0.0/8,localhost",
}})
require.NoError(t, err, mode)
opts := cfg.Destinations.ToConfig(cfg)
assert.True(t, opts.Webhook.BlockPrivateAddresses, mode)
assert.Equal(t, []string{"10.0.0.0/8", "localhost"}, opts.PrivateAddressAllowlist, mode)
}
})

t.Run("yaml", func(t *testing.T) {
cfg, err := config.ParseWithoutValidation(config.Flags{}, &mockOS{
files: map[string][]byte{"config.yaml": []byte(`
destinations:
private_address_allowlist:
- 10.0.0.0/8
- 192.168.1.5
webhook:
block_private_addresses: true
`)},
envVars: map[string]string{"CONFIG": "config.yaml"},
})
require.NoError(t, err)
opts := cfg.Destinations.ToConfig(cfg)
assert.True(t, opts.Webhook.BlockPrivateAddresses)
assert.Equal(t, []string{"10.0.0.0/8", "192.168.1.5"}, opts.PrivateAddressAllowlist)
})
}

func TestValidate_PrivateAddressAllowlist(t *testing.T) {
for name, tc := range map[string]struct {
entries []string
block bool
wantErr bool
}{
"empty": {},
"valid": {entries: []string{"10.0.0.0/8", "127.0.0.1", "localhost", "fd00::/8"}},
"host name": {entries: []string{"internal.example"}, wantErr: true},
"whole internet": {entries: []string{"0.0.0.0/0"}, wantErr: true},
"host bits": {entries: []string{"10.0.0.1/8"}, wantErr: true},
"bad entry, block on": {entries: []string{"nope"}, block: true, wantErr: true},
"bad entry, block off": {entries: []string{"nope"}, wantErr: true},
} {
t.Run(name, func(t *testing.T) {
c := validConfig()
c.Destinations.PrivateAddressAllowlist = tc.entries
c.Destinations.Webhook.BlockPrivateAddresses = tc.block
err := c.Validate(config.Flags{})
if tc.wantErr {
assert.ErrorIs(t, err, config.ErrInvalidPrivateAddressAllowlist)
} else {
assert.NoError(t, err)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"regexp"
"strings"

"github.com/hookdeck/outpost/internal/netguard"
"github.com/hookdeck/outpost/internal/proxychain"
)

Expand Down Expand Up @@ -202,6 +203,9 @@ func (c *Config) validateDestinations() error {
if _, err := proxychain.Parse(c.Destinations.Webhook.ProxyURL); err != nil {
return fmt.Errorf("%w: %w", ErrInvalidWebhookProxyURL, err)
}
if _, err := netguard.ParseAllowlist(c.Destinations.PrivateAddressAllowlist); err != nil {
return fmt.Errorf("%w: %w", ErrInvalidPrivateAddressAllowlist, err)
}
switch source := c.Destinations.AWSEventBridge.Source; {
case source == "":
return fmt.Errorf("%w: DESTINATIONS_AWS_EVENTBRIDGE_SOURCE must not be empty", ErrInvalidAWSEventBridgeSource)
Expand Down
Loading
Loading