validator.go

  1package webhook
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"net"
  7	"net/url"
  8	"slices"
  9	"strings"
 10)
 11
 12var (
 13	// ErrInvalidScheme is returned when the webhook URL scheme is not http or https.
 14	ErrInvalidScheme = errors.New("webhook URL must use http or https scheme")
 15	// ErrPrivateIP is returned when the webhook URL resolves to a private IP address.
 16	ErrPrivateIP = errors.New("webhook URL cannot resolve to private or internal IP addresses")
 17	// ErrInvalidURL is returned when the webhook URL is invalid.
 18	ErrInvalidURL = errors.New("invalid webhook URL")
 19)
 20
 21// ValidateWebhookURL validates that a webhook URL is safe to use.
 22// It checks:
 23// - URL is properly formatted
 24// - Scheme is http or https
 25// - Hostname does not resolve to private/internal IP addresses
 26// - Hostname is not localhost or similar.
 27func ValidateWebhookURL(rawURL string) error {
 28	if rawURL == "" {
 29		return ErrInvalidURL
 30	}
 31
 32	// Parse the URL
 33	u, err := url.Parse(rawURL)
 34	if err != nil {
 35		return fmt.Errorf("%w: %v", ErrInvalidURL, err)
 36	}
 37
 38	// Check scheme
 39	if u.Scheme != "http" && u.Scheme != "https" {
 40		return ErrInvalidScheme
 41	}
 42
 43	// Extract hostname (without port)
 44	hostname := u.Hostname()
 45	if hostname == "" {
 46		return fmt.Errorf("%w: missing hostname", ErrInvalidURL)
 47	}
 48
 49	// Check for localhost variations
 50	if isLocalhost(hostname) {
 51		return ErrPrivateIP
 52	}
 53
 54	// If it's an IP address, validate it directly
 55	if ip := net.ParseIP(hostname); ip != nil {
 56		if isPrivateOrInternalIP(ip) {
 57			return ErrPrivateIP
 58		}
 59		return nil
 60	}
 61
 62	// Resolve hostname to IP addresses
 63	ips, err := net.LookupIP(hostname)
 64	if err != nil {
 65		return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err)
 66	}
 67
 68	// Check all resolved IPs
 69	if slices.ContainsFunc(ips, isPrivateOrInternalIP) {
 70		return ErrPrivateIP
 71	}
 72
 73	return nil
 74}
 75
 76// isLocalhost checks if the hostname is localhost or similar.
 77func isLocalhost(hostname string) bool {
 78	hostname = strings.ToLower(hostname)
 79	return hostname == "localhost" ||
 80		hostname == "localhost.localdomain" ||
 81		strings.HasSuffix(hostname, ".localhost")
 82}
 83
 84// isPrivateOrInternalIP checks if an IP address is private, internal, or reserved.
 85func isPrivateOrInternalIP(ip net.IP) bool {
 86	// Loopback addresses (127.0.0.0/8, ::1)
 87	if ip.IsLoopback() {
 88		return true
 89	}
 90
 91	// Link-local addresses (169.254.0.0/16, fe80::/10)
 92	// This blocks AWS/GCP/Azure metadata services
 93	if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
 94		return true
 95	}
 96
 97	// Private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
 98	if ip.IsPrivate() {
 99		return true
100	}
101
102	// Unspecified addresses (0.0.0.0, ::)
103	if ip.IsUnspecified() {
104		return true
105	}
106
107	// Multicast addresses
108	if ip.IsMulticast() {
109		return true
110	}
111
112	// Additional checks for IPv4
113	if ip4 := ip.To4(); ip4 != nil {
114		// 0.0.0.0/8 (current network)
115		if ip4[0] == 0 {
116			return true
117		}
118		// 100.64.0.0/10 (Shared Address Space)
119		if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
120			return true
121		}
122		// 192.0.0.0/24 (IETF Protocol Assignments)
123		if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 {
124			return true
125		}
126		// 192.0.2.0/24 (TEST-NET-1)
127		if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 {
128			return true
129		}
130		// 198.18.0.0/15 (benchmarking)
131		if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) {
132			return true
133		}
134		// 198.51.100.0/24 (TEST-NET-2)
135		if ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100 {
136			return true
137		}
138		// 203.0.113.0/24 (TEST-NET-3)
139		if ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113 {
140			return true
141		}
142		// 224.0.0.0/4 (Multicast - already handled by IsMulticast)
143		// 240.0.0.0/4 (Reserved for future use)
144		if ip4[0] >= 240 {
145			return true
146		}
147		// 255.255.255.255/32 (Broadcast)
148		if ip4[0] == 255 && ip4[1] == 255 && ip4[2] == 255 && ip4[3] == 255 {
149			return true
150		}
151	}
152
153	return false
154}
155
156// ValidateIPBeforeDial validates an IP address before establishing a connection.
157// This is used to prevent DNS rebinding attacks.
158func ValidateIPBeforeDial(ip net.IP) error {
159	if isPrivateOrInternalIP(ip) {
160		return ErrPrivateIP
161	}
162	return nil
163}