From 2c033c58f2d94a4b881c4c29c730331e6bcd0c35 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 19:08:01 -0400 Subject: [PATCH] fix(canary): webhook sender blocks SSRF to private/loopback/IMDS hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/internal/notify/webhook/sender.go | 171 +++++++++++++++--- .../internal/notify/webhook/sender_test.go | 86 ++++++++- 2 files changed, 229 insertions(+), 28 deletions(-) diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender.go index a9aa47aa..3abc2820 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender.go @@ -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"` diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go index c95cee3e..f2d53e7c 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go @@ -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(