Files
gochat/internal/security/ssrf_protection.go
T
2026-06-04 15:44:48 +08:00

227 lines
6.4 KiB
Go

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)
}