227 lines
6.4 KiB
Go
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)
|
|
} |