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:
parent
30ebda39d5
commit
2c033c58f2
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue