2026-02-14 17:24:39 +00:00
|
|
|
package validators
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// blockedHostnames contains hostnames that must never be used as webhook targets (SSRF protection).
|
|
|
|
|
var blockedHostnames = []string{
|
|
|
|
|
"localhost",
|
|
|
|
|
"127.0.0.1",
|
|
|
|
|
"metadata",
|
|
|
|
|
"metadata.google.internal",
|
|
|
|
|
"metadata.google",
|
|
|
|
|
"instance-data",
|
|
|
|
|
"169.254.169.254",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IsInternalOrPrivateURL returns true if the URL targets an internal or private address (SSRF risk).
|
|
|
|
|
// Blocks: localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.x (cloud metadata).
|
|
|
|
|
func IsInternalOrPrivateURL(rawURL string) bool {
|
|
|
|
|
parsed, err := url.Parse(rawURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return true // Invalid URL treated as unsafe
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
|
|
|
if scheme != "http" && scheme != "https" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hostname := strings.ToLower(parsed.Hostname())
|
|
|
|
|
if hostname == "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, blocked := range blockedHostnames {
|
|
|
|
|
if hostname == blocked {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ips, err := net.LookupIP(hostname)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return true // Resolution failure treated as unsafe
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ip := range ips {
|
|
|
|
|
if isPrivateIP(ip) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isPrivateIP(ip net.IP) bool {
|
|
|
|
|
privateRanges := []string{
|
|
|
|
|
"10.0.0.0/8", // RFC1918
|
|
|
|
|
"172.16.0.0/12", // RFC1918
|
|
|
|
|
"192.168.0.0/16", // RFC1918
|
|
|
|
|
"127.0.0.0/8", // Loopback
|
|
|
|
|
"169.254.0.0/16", // Link-local / cloud metadata
|
2026-03-05 22:03:43 +00:00
|
|
|
"::1/128", // IPv6 loopback
|
2026-02-14 17:24:39 +00:00
|
|
|
"fc00::/7", // IPv6 unique local
|
|
|
|
|
"fe80::/10", // IPv6 link-local
|
|
|
|
|
}
|
|
|
|
|
for _, cidrStr := range privateRanges {
|
|
|
|
|
_, cidr, err := net.ParseCIDR(cidrStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if cidr.Contains(ip) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:31:10 +00:00
|
|
|
// ValidateWebhookURL validates that a webhook URL is safe for registration.
|
|
|
|
|
// SEC-07: Only HTTPS allowed. Blocks private/internal IPs (SSRF protection).
|
2026-02-14 17:24:39 +00:00
|
|
|
func ValidateWebhookURL(rawURL string) error {
|
|
|
|
|
parsed, err := url.Parse(rawURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
|
|
|
}
|
|
|
|
|
scheme := strings.ToLower(parsed.Scheme)
|
2026-02-22 16:31:10 +00:00
|
|
|
if scheme != "https" {
|
|
|
|
|
return fmt.Errorf("only https URLs are allowed for webhooks (got %q)", parsed.Scheme)
|
2026-02-14 17:24:39 +00:00
|
|
|
}
|
|
|
|
|
hostname := strings.ToLower(parsed.Hostname())
|
|
|
|
|
if hostname == "" {
|
|
|
|
|
return fmt.Errorf("URL must have a hostname")
|
|
|
|
|
}
|
|
|
|
|
if IsInternalOrPrivateURL(rawURL) {
|
|
|
|
|
return fmt.Errorf("URL targets internal or private address - not allowed for webhooks")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|