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: 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)) }, MaxIdleConns: 16, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, } return &http.Client{Timeout: timeout, Transport: transport} } // 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" } 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" } return "" }