veza/veza-backend-api/internal/validators/url_validator.go
2026-03-05 23:03:43 +01:00

100 lines
2.4 KiB
Go

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
"::1/128", // IPv6 loopback
"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
}
// ValidateWebhookURL validates that a webhook URL is safe for registration.
// SEC-07: Only HTTPS allowed. Blocks private/internal IPs (SSRF protection).
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)
if scheme != "https" {
return fmt.Errorf("only https URLs are allowed for webhooks (got %q)", parsed.Scheme)
}
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
}