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}