package staticsite import ( "context" "errors" "fmt" "net" "net/http" "net/url" "strings" "time" ) // ErrBlockedAddress is returned when the dialer refuses to connect // to a reserved IP (loopback / link-local / unspecified / multicast). // RFC1918 private ranges are intentionally allowed — self-hosted Gitea // on a LAN is the dominant deployment pattern. var ErrBlockedAddress = errors.New("connection to reserved address blocked") // ValidateBaseURL enforces scheme + host shape on a user-supplied // provider base URL. Connect-time IP filtering happens later in the // safe-HTTP transport so DNS rebinding cannot bypass this check. func ValidateBaseURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { return errors.New("base_url is required") } u, err := url.Parse(raw) if err != nil { return fmt.Errorf("invalid base_url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("unsupported scheme %q (must be http or https)", u.Scheme) } if u.Host == "" { return errors.New("base_url is missing host") } return nil } // NewSafeHTTPClient returns an http.Client whose DialContext rejects // loopback, link-local, multicast, and unspecified addresses at connect // time. The dialer re-resolves and connects to the resolved IP so a // rebind between resolution and connect cannot slip through. // // RFC1918 / ULA private ranges are NOT blocked — operators routinely // point Tinyforge at self-hosted Gitea instances on private networks. // The threat model here is cloud-metadata exfiltration and loopback // service probing, not "any private IP is suspect". func NewSafeHTTPClient(timeout time.Duration) *http.Client { dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} transport := &http.Transport{ DialContext: SafeDialContext(dialer), MaxIdleConns: 16, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, } return &http.Client{Timeout: timeout, Transport: transport} } // SafeDialContext returns a DialContext that rejects loopback, link-local, // multicast, unspecified, and cloud-metadata addresses at connect time, // re-resolving and binding to the resolved IP so a DNS rebind between // resolution and connect cannot slip through. Exposed so other transports // (e.g. the outbound notification client) can apply the same SSRF policy // without duplicating it or losing their own connection-pool tuning. func SafeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } // If the caller passed a literal IP, skip the DNS round-trip. if literal := net.ParseIP(host); literal != nil { if reason := blockReason(literal); reason != "" { return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason) } return dialer.DialContext(ctx, network, addr) } ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } if len(ips) == 0 { return nil, fmt.Errorf("no addresses for %s", host) } for _, ip := range ips { if reason := blockReason(ip.IP); reason != "" { return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason) } } // Bind to the first resolved IP so a rebind between resolution // and connect cannot redirect the request to a blocked address. return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) } } // blockReason returns a human label for why an IP is rejected, or "" // if the IP is allowed. Centralized so all callers share the same // policy. func blockReason(ip net.IP) string { if ip == nil { return "nil address" } // Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) so the loopback / link-local // classifiers below catch them. net.IP.To4() returns the 4-byte form for // IPv4-mapped addresses; net's IsLoopback already handles this, but pin // the conversion to avoid future surprises if the std-lib semantics drift. if v4 := ip.To4(); v4 != nil { ip = v4 } switch { case ip.IsLoopback(): return "loopback" case ip.IsUnspecified(): return "unspecified" case ip.IsLinkLocalUnicast(): return "link-local" case ip.IsLinkLocalMulticast(): return "link-local multicast" case ip.IsMulticast(): return "multicast" } // Cloud metadata endpoints — AWS / GCP / Azure are covered by the // link-local block (169.254.169.254). The rest must be enumerated. if metadataIPSet[ip.String()] { return "cloud metadata endpoint" } return "" } // metadataIPSet enumerates well-known cloud metadata IPs that are NOT // covered by net.IP.IsLinkLocalUnicast. Updating this set is the lightest // way to keep up with new providers without changing the policy shape. var metadataIPSet = map[string]bool{ // Alibaba Cloud ECS metadata. "100.100.100.200": true, // Oracle Cloud Infrastructure metadata. "192.0.0.192": true, // AWS IMDS over IPv6 (ULA — not link-local, must be listed). "fd00:ec2::254": true, }