package security // Reference: P14 Deliverable #2 — SSRF Protection // Prevents Server-Side Request Forgery attacks in outbound HTTP requests. // Chatwoot uses lib/safe_fetch.rb for webhook URL validation; gochat needs equivalent. import ( "context" "fmt" "net" "net/http" "net/url" "strings" "time" ) // --- Security Audit Findings --- // // 1. CRITICAL: Webhook handler accepts arbitrary channel_type and inbox_id from URL params // with no SSRF validation. When providers make outbound HTTP calls (e.g., Telegram API), // an attacker controlling inbox config could redirect to internal services. // // 2. HIGH: No validation on URLs that providers might fetch. OAuth callback URLs, // webhook verification URLs, and avatar URLs are all potential SSRF vectors. // // 3. MEDIUM: No DNS rebinding prevention. An attacker could register a domain that // resolves to an internal IP after initial resolution. // // Chatwoot's safe_fetch.rb validates: // - Resolves hostname and blocks private/reserved IPs // - Blocks link-local, loopback, and multicast addresses // - Uses custom DNS resolver to prevent rebinding // SSRFConfig holds SSRF protection configuration. type SSRFConfig struct { AllowedDomains []string // whitelist of domains bypassing SSRF checks BlockedCIDRs []string // IP ranges forbidden (private, loopback, etc.) MaxRedirects int // limit HTTP redirect chains RequireTLS bool // enforce HTTPS for certain operations } // DefaultSSRFConfig returns safe defaults matching Chatwoot's safe_fetch.rb. func DefaultSSRFConfig() SSRFConfig { return SSRFConfig{ AllowedDomains: []string{ "api.telegram.org", "graph.facebook.com", "api.instagram.com", "business.facebook.com", "web.whatsapp.com", }, BlockedCIDRs: []string{ "10.0.0.0/8", // RFC 1918 private "172.16.0.0/12", // RFC 1918 private "192.168.0.0/16", // RFC 1918 private "127.0.0.0/8", // Loopback "0.0.0.0/8", // Current network "100.64.0.0/10", // CGN "169.254.0.0/16", // Link-local "192.0.0.0/24", // IETF Protocol Assignments "192.0.2.0/24", // TEST-NET-1 "198.18.0.0/15", // Benchmarking "224.0.0.0/4", // Multicast "240.0.0.0/4", // Reserved "::1/128", // IPv6 loopback "fc00::/7", // IPv6 unique local "fe80::/10", // IPv6 link-local }, MaxRedirects: 3, RequireTLS: false, } } // SafeHTTPClient wraps http.Client with SSRF protection. type SafeHTTPClient struct { client *http.Client cfg SSRFConfig } // NewSafeHTTPClient creates an HTTP client that blocks requests to private IPs. func NewSafeHTTPClient(cfg SSRFConfig) *SafeHTTPClient { dialer := &net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, } transport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf("invalid address: %s", addr) } ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("DNS resolution failed for %s: %w", host, err) } for _, ip := range ips { if isBlockedIP(ip.IP, cfg.BlockedCIDRs) { return nil, fmt.Errorf("SSRF blocked: %s resolves to private IP %s", host, ip.IP) } } // DNS rebinding check ips2, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("DNS rebinding check failed for %s: %w", host, err) } if !sameIPSets(ips, ips2) { return nil, fmt.Errorf("DNS rebinding detected: %s resolved to different IPs", host) } return dialer.DialContext(ctx, network, net.JoinHostPort(host, port)) }, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, } return &SafeHTTPClient{ client: &http.Client{ Transport: transport, Timeout: 30 * time.Second, CheckRedirect: safeRedirectCheck(cfg.MaxRedirects), }, cfg: cfg, } } // Do executes an HTTP request with SSRF protection. func (c *SafeHTTPClient) Do(req *http.Request) (*http.Response, error) { host := req.URL.Hostname() // Whitelist bypass for _, allowed := range c.cfg.AllowedDomains { if host == allowed || strings.HasSuffix(host, "."+allowed) { return c.client.Do(req) } } if c.cfg.RequireTLS && req.URL.Scheme != "https" { return nil, fmt.Errorf("SSRF protection: non-HTTPS request blocked for %s", req.URL) } if net.ParseIP(host) != nil { if isBlockedIP(net.ParseIP(host), c.cfg.BlockedCIDRs) { return nil, fmt.Errorf("SSRF blocked: direct IP request to %s", host) } } return c.client.Do(req) } func isBlockedIP(ip net.IP, blockedCIDRs []string) bool { for _, cidr := range blockedCIDRs { _, network, err := net.ParseCIDR(cidr) if err != nil { continue } if network.Contains(ip) { return true } } return false } func safeRedirectCheck(maxRedirects int) func(req *http.Request, via []*http.Request) error { return func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("SSRF: stopped after %d redirects", maxRedirects) } host := req.URL.Hostname() if net.ParseIP(host) != nil { return fmt.Errorf("SSRF: redirect to IP literal blocked: %s", host) } return nil } } func sameIPSets(a, b []net.IPAddr) bool { if len(a) != len(b) { return false } setA := make(map[string]bool) for _, ip := range a { setA[ip.IP.String()] = true } for _, ip := range b { if !setA[ip.IP.String()] { return false } } return true } // ValidateURL checks if a URL is safe to fetch (without making a request). func ValidateURL(rawURL string, cfg SSRFConfig) error { if strings.TrimSpace(rawURL) == "" { return fmt.Errorf("empty URL") } parsed, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } host := parsed.Hostname() for _, allowed := range cfg.AllowedDomains { if host == allowed || strings.HasSuffix(host, "."+allowed) { return nil } } if ip := net.ParseIP(host); ip != nil { if isBlockedIP(ip, cfg.BlockedCIDRs) { return fmt.Errorf("SSRF: URL points to private/reserved IP %s", host) } } return nil } // SafeFetchURL fetches a URL safely with SSRF protection. func (c *SafeHTTPClient) SafeFetchURL(ctx context.Context, rawurl string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, "GET", rawurl, nil) if err != nil { return nil, fmt.Errorf("invalid request: %w", err) } return c.Do(req) }