fix(canary): webhook sender blocks SSRF to private/loopback/IMDS hosts

Audit F3: validateURL checked scheme/host/userinfo but never resolved the
host, so an operator-supplied webhook_url could point at the canary's own
Redis (redis:6379), Postgres, link-local IMDS (169.254.169.254), or any
RFC1918 host on the docker network or VPS subnet — a classic confused-deputy
SSRF triggered by self-triggering a token after creation.

validateURL now resolves the hostname (or parses a literal IP) and rejects
loopback, RFC1918, CGNAT, link-local, multicast, unspecified, IMDS, and
IPv6 unique-local. The default HTTP client also installs a DialContext that
re-checks the dialed IP for defense-in-depth against DNS rebinding. The
Config gains an AllowPrivateHosts flag (default false) that test code opts
into when targeting httptest.NewServer.
This commit is contained in:
CarterPerez-dev 2026-05-17 19:08:01 -04:00
parent 30ebda39d5
commit 2c033c58f2
2 changed files with 229 additions and 28 deletions

View File

@ -47,16 +47,20 @@ var (
"webhook: webhook URL not configured",
)
ErrInvalidWebhookURL = errors.New("webhook: invalid url")
ErrWebhookAPI = errors.New("webhook: api error")
ErrBlockedHost = errors.New(
"webhook: host resolves to a blocked address range",
)
ErrWebhookAPI = errors.New("webhook: api error")
)
type Config struct {
ManageURL string
HMACSecret string
HTTPClient *http.Client
MaxTries uint
MaxElapsed time.Duration
InitialInterval time.Duration
ManageURL string
HMACSecret string
HTTPClient *http.Client
MaxTries uint
MaxElapsed time.Duration
InitialInterval time.Duration
AllowPrivateHosts bool
}
type Option func(*Config)
@ -75,13 +79,18 @@ func WithHTTPClient(client *http.Client) Option {
return func(c *Config) { c.HTTPClient = client }
}
func WithAllowPrivateHosts(allow bool) Option {
return func(c *Config) { c.AllowPrivateHosts = allow }
}
type Sender struct {
manageURL string
hmacSecret string
httpClient *http.Client
maxTries uint
maxElapsed time.Duration
initialInterval time.Duration
manageURL string
hmacSecret string
httpClient *http.Client
maxTries uint
maxElapsed time.Duration
initialInterval time.Duration
allowPrivateHosts bool
}
func NewSender(cfg Config, opts ...Option) *Sender {
@ -89,7 +98,7 @@ func NewSender(cfg Config, opts ...Option) *Sender {
o(&cfg)
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = defaultHTTPClient()
cfg.HTTPClient = defaultHTTPClient(cfg.AllowPrivateHosts)
}
if cfg.MaxTries == 0 {
cfg.MaxTries = defaultMaxTries
@ -101,21 +110,45 @@ func NewSender(cfg Config, opts ...Option) *Sender {
cfg.InitialInterval = defaultInitialInterval
}
return &Sender{
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
hmacSecret: cfg.HMACSecret,
httpClient: cfg.HTTPClient,
maxTries: cfg.MaxTries,
maxElapsed: cfg.MaxElapsed,
initialInterval: cfg.InitialInterval,
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
hmacSecret: cfg.HMACSecret,
httpClient: cfg.HTTPClient,
maxTries: cfg.MaxTries,
maxElapsed: cfg.MaxElapsed,
initialInterval: cfg.InitialInterval,
allowPrivateHosts: cfg.AllowPrivateHosts,
}
}
func defaultHTTPClient() *http.Client {
dialer := &net.Dialer{Timeout: defaultDialTimeout}
func defaultHTTPClient(allowPrivateHosts bool) *http.Client {
base := &net.Dialer{Timeout: defaultDialTimeout}
dialFn := base.DialContext
if !allowPrivateHosts {
dialFn = func(
ctx context.Context,
network, addr string,
) (net.Conn, error) {
if err := preDialIPCheck(addr); err != nil {
return nil, err
}
conn, err := base.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
if err := postDialIPCheck(conn); err != nil {
if cErr := conn.Close(); cErr != nil {
slog.Warn("webhook: close blocked conn",
"error", cErr)
}
return nil, err
}
return conn, nil
}
}
return &http.Client{
Timeout: defaultOverallTimeout,
Transport: &http.Transport{
DialContext: dialer.DialContext,
DialContext: dialFn,
TLSHandshakeTimeout: defaultDialTimeout,
ResponseHeaderTimeout: defaultOverallTimeout,
ExpectContinueTimeout: time.Second,
@ -124,8 +157,38 @@ func defaultHTTPClient() *http.Client {
}
}
func preDialIPCheck(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("webhook: split host port: %w", err)
}
ip := net.ParseIP(host)
if ip == nil {
return nil
}
if isBlockedIP(ip) {
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, ip)
}
return nil
}
func postDialIPCheck(conn net.Conn) error {
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok {
return nil
}
if isBlockedIP(tcpAddr.IP) {
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, tcpAddr.IP)
}
return nil
}
func (s *Sender) Channel() string { return Channel }
func (s *Sender) Validate(raw string) error {
return validateURL(raw, s.allowPrivateHosts)
}
func (s *Sender) Send(
ctx context.Context,
info event.NotifyInfo,
@ -134,7 +197,7 @@ func (s *Sender) Send(
if strings.TrimSpace(info.WebhookURL) == "" {
return ErrChannelNotConfigured
}
if err := validateURL(info.WebhookURL); err != nil {
if err := validateURL(info.WebhookURL, s.allowPrivateHosts); err != nil {
return err
}
@ -215,7 +278,7 @@ func (s *Sender) doRequest(
}
}
func validateURL(raw string) error {
func validateURL(raw string, allowPrivateHosts bool) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%w: parse: %w", ErrInvalidWebhookURL, err)
@ -233,9 +296,67 @@ func validateURL(raw string) error {
if u.User != nil {
return fmt.Errorf("%w: userinfo not allowed", ErrInvalidWebhookURL)
}
if allowPrivateHosts {
return nil
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("%w: missing hostname", ErrInvalidWebhookURL)
}
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("%w: %s", ErrBlockedHost, ip)
}
return nil
}
ips, lookupErr := net.LookupIP(host)
if lookupErr != nil {
return fmt.Errorf(
"%w: lookup %s: %w",
ErrInvalidWebhookURL, host, lookupErr,
)
}
if len(ips) == 0 {
return fmt.Errorf("%w: no IPs for %s", ErrInvalidWebhookURL, host)
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("%w: %s -> %s", ErrBlockedHost, host, ip)
}
}
return nil
}
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
switch {
case ip4[0] == 10:
return true
case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31:
return true
case ip4[0] == 192 && ip4[1] == 168:
return true
case ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127:
return true
case ip4[0] == 169 && ip4[1] == 254:
return true
}
return false
}
if len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc {
return true
}
return false
}
type envelope struct {
Version string `json:"version"`
Event string `json:"event"`

View File

@ -98,10 +98,18 @@ func loadBody(t *testing.T, c *capture) []byte {
func newSender(t *testing.T, opts ...webhook.Option) *webhook.Sender {
t.Helper()
return webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
ManageURL: "https://canary.example.com",
AllowPrivateHosts: true,
}, opts...)
}
func newStrictSender(t *testing.T) *webhook.Sender {
t.Helper()
return webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
})
}
func TestSender_Channel(t *testing.T) {
t.Parallel()
require.Equal(t, "webhook", newSender(t).Channel())
@ -197,8 +205,9 @@ func TestSender_Send_HMACSignatureWhenSecretSet(t *testing.T) {
},
)
s := webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
HMACSecret: secret,
ManageURL: "https://canary.example.com",
HMACSecret: secret,
AllowPrivateHosts: true,
})
require.NoError(
t,
@ -326,6 +335,77 @@ func TestSender_Send_AbortsAfterMaxTries(t *testing.T) {
require.Equal(t, int32(3), attempts.Load())
}
func TestSender_Validate_BlocksPrivateHosts(t *testing.T) {
t.Parallel()
s := newStrictSender(t)
cases := []string{
"http://127.0.0.1/",
"http://127.0.0.1:6379/",
"http://10.0.0.1/",
"http://10.255.255.255/",
"http://172.16.0.1/",
"http://172.31.255.255/",
"http://192.168.1.1/",
"http://169.254.169.254/latest/meta-data/",
"http://100.64.0.1/",
"http://0.0.0.0/",
"http://[::1]/",
"http://[fd00::1]/",
"http://[fe80::1]/",
}
for _, raw := range cases {
raw := raw
t.Run(raw, func(t *testing.T) {
t.Parallel()
err := s.Validate(raw)
require.ErrorIsf(
t,
err,
webhook.ErrBlockedHost,
"expected ErrBlockedHost for %q",
raw,
)
})
}
}
func TestSender_Validate_AllowsPublicLiteralIPs(t *testing.T) {
t.Parallel()
s := newStrictSender(t)
cases := []string{
"https://8.8.8.8/",
"https://1.1.1.1/",
"http://203.0.113.45:8080/hook",
"https://[2001:db8::1]/",
}
for _, raw := range cases {
raw := raw
t.Run(raw, func(t *testing.T) {
t.Parallel()
require.NoError(t, s.Validate(raw))
})
}
}
func TestSender_Send_BlocksPrivateHostByDefault(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
t.Cleanup(srv.Close)
s := newStrictSender(t)
err := s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent())
require.ErrorIs(
t,
err,
webhook.ErrBlockedHost,
"strict sender must refuse to send to loopback URL",
)
}
func TestSender_Send_RespectsContextCancel(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(