validator.go

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