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}