package security import ( "context" "errors" "fmt" "net" "net/url" "os" "strings" "time" ) var ( ErrInvalidURL = errors.New("invalid url") ErrBlockedURL = errors.New("url host is not allowed") ErrBlockedHost = errors.New("host is not allowed") ) // ValidatePublicHTTPSURL checks logo / webhook / CTA URLs for SSRF. // Allows http only for loopback hosts (local dev). Does not fetch the URL. // In production (APP_ENV=production|prod), loopback hosts are rejected. func ValidatePublicHTTPSURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil } if len(raw) > 2048 { return "", ErrInvalidURL } if !strings.Contains(raw, "://") { raw = "https://" + raw } u, err := url.Parse(raw) if err != nil || u.Host == "" { return "", ErrInvalidURL } scheme := strings.ToLower(u.Scheme) if scheme != "https" && scheme != "http" { return "", ErrInvalidURL } // Reject credentialed URLs (userinfo tricks / accidental secret leakage). if u.User != nil { return "", ErrInvalidURL } host := strings.ToLower(u.Hostname()) if host == "" { return "", ErrInvalidURL } if host == "metadata.google.internal" || host == "metadata" || strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".intranet") { return "", ErrBlockedURL } allowLoopback := !isProductionAppEnv() if err := AssertPublicHost(context.Background(), host, allowLoopback); err != nil { return "", err } if scheme == "http" && !isLoopbackHost(host) { return "", fmt.Errorf("%w: https required (http only allowed for localhost)", ErrInvalidURL) } u.Fragment = "" return u.String(), nil } func isProductionAppEnv() bool { e := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) return e == "production" || e == "prod" } // DialPolicy controls which non-public destinations SafeHTTP* may dial. // Production callers should leave both flags false (fail-closed SSRF). type DialPolicy struct { AllowLoopback bool // localhost / 127.0.0.0/8 / ::1 AllowPrivate bool // RFC1918 / ULA only; never link-local, CGNAT, or metadata } // AssertPublicHost rejects private / link-local / metadata targets. // allowLoopback permits localhost (SMTP Mailhog, local webhooks). func AssertPublicHost(ctx context.Context, host string, allowLoopback bool) error { return AssertHost(ctx, host, DialPolicy{AllowLoopback: allowLoopback}) } // AssertHost rejects private / link-local / metadata targets per DialPolicy. func AssertHost(ctx context.Context, host string, policy DialPolicy) error { host = strings.ToLower(strings.TrimSpace(host)) if host == "" { return ErrBlockedHost } if host == "metadata.google.internal" || host == "metadata" { return ErrBlockedHost } if isLoopbackHost(host) { if policy.AllowLoopback { return nil } return ErrBlockedHost } ips, err := resolveHostIPs(ctx, host) if err != nil { if net.ParseIP(host) != nil { return err } // Unresolvable non-literal host: reject fail-closed for dial targets. return ErrBlockedHost } for _, ip := range ips { if ip.IsLoopback() { if !policy.AllowLoopback { return ErrBlockedHost } continue } if isBlockedIP(ip) { if policy.AllowPrivate && isPrivateLANIP(ip) { continue } return ErrBlockedHost } } return nil } // AssertDialableSMTPHost validates an SMTP hostname before dial / send. func AssertDialableSMTPHost(ctx context.Context, host string) error { return AssertPublicHost(ctx, host, true) } // ValidateShopifyShopDomain normalizes a Shopify Admin API shop hostname. // Prefer shopify.NormalizeShopDomain in the Shopify package; this helper is the // shared security entry point for callers outside that package. func ValidateShopifyShopDomain(raw string) (string, error) { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" { return "", ErrInvalidURL } if len(raw) > 255 { return "", ErrInvalidURL } if strings.Contains(raw, "://") { u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "", ErrInvalidURL } raw = u.Hostname() } raw = strings.TrimSuffix(raw, "/") if i := strings.IndexAny(raw, "/?#"); i >= 0 { raw = raw[:i] } if strings.Contains(raw, ":") { host, _, err := net.SplitHostPort(raw) if err != nil { return "", ErrInvalidURL } raw = host } if net.ParseIP(raw) != nil { return "", ErrBlockedURL } if !strings.HasSuffix(raw, ".myshopify.com") { if strings.Contains(raw, ".") { return "", fmt.Errorf("%w: shop must be *.myshopify.com", ErrBlockedURL) } raw = raw + ".myshopify.com" } shop := strings.TrimSuffix(raw, ".myshopify.com") if shop == "" || strings.Contains(shop, ".") { return "", ErrInvalidURL } for _, r := range shop { ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' if !ok { return "", ErrInvalidURL } } // Format-only; dial with SafeHTTPClient(allowLoopback=false) for runtime SSRF. return raw, nil } func resolveHostIPs(ctx context.Context, host string) ([]net.IP, error) { if ip := net.ParseIP(host); ip != nil { return []net.IP{ip}, nil } if ctx == nil { ctx = context.Background() } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, ErrInvalidURL } out := make([]net.IP, 0, len(addrs)) for _, a := range addrs { out = append(out, a.IP) } return out, nil } func isLoopbackHost(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // isPrivateLANIP reports RFC1918 / ULA addresses that AllowPrivate may dial. // Link-local (incl. cloud metadata 169.254.169.254) stays excluded. func isPrivateLANIP(ip net.IP) bool { if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } return ip.IsPrivate() } func isBlockedIP(ip net.IP) bool { if ip.IsLoopback() { return false // loopback gated by allowLoopback at host level } if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true } if ip4 := ip.To4(); ip4 != nil { if ip4[0] == 10 { return true } if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { return true } if ip4[0] == 192 && ip4[1] == 168 { return true } if ip4[0] == 169 && ip4[1] == 254 { return true } if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { return true } } else if ip.IsPrivate() { return true } return false }