Gitfed
bastien-mrq/gitfed / internal / federation / wellknown.go
// Package federation implements cross-instance identity discovery and the
// remote-CA trust store, per DESIGN.md §5.2.
package federation

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"regexp"
	"strings"
	"time"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
)

// WellKnown is the document served at /.well-known/gitfed.json.
type WellKnown struct {
	Version     int    `json:"version"`
	Domain      string `json:"domain"`
	CAPublicKey string `json:"ca_public_key"`
	Software    string `json:"software"`
	Contact     string `json:"contact,omitempty"`
}

// Handler returns an http.Handler serving this instance's well-known
// federation document.
func Handler(domain, contact, softwareVersion string, localCA *ca.CA) http.Handler {
	doc := WellKnown{
		Version:     1,
		Domain:      domain,
		CAPublicKey: localCA.PublicKeyAuthorized(),
		Software:    "git.neuromancer.ovh/bastien-mrq/gitfed/" + softwareVersion,
		Contact:     contact,
	}
	body, _ := json.MarshalIndent(doc, "", "  ")

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write(body)
	})
}

// maxWellKnownBytes caps how much of a remote well-known response we read, so
// a hostile or broken peer can't exhaust memory during discovery.
const maxWellKnownBytes = 1 << 20 // 1 MiB

// insecureClient is used only in dev/testing (insecure=true), where the peer
// is a loopback instance with no TLS and possibly a private address — so it
// deliberately does not apply the anti-SSRF dial guard.
var insecureClient = &http.Client{Timeout: 10 * time.Second}

// secureClient is used for real remote discovery. Its dialer refuses to
// connect to non-public addresses and pins the connection to the exact IP it
// validated, closing the DNS-rebinding window between check and dial.
var secureClient = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		DialContext:           guardedDial,
		TLSHandshakeTimeout:   10 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second,
	},
}

// hostnameRe matches a conventional public hostname (at least two dot-joined
// labels, alphabetic TLD). It rejects bare hostnames, IP literals and
// anything carrying a port, path, userinfo or scheme.
var hostnameRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)

// ValidatePublicDomain reports whether domain is safe to make an outbound
// federation request to: a syntactically valid public hostname, not an IP
// literal and with no embedded port/path/credentials. The IP-level block is
// enforced again at dial time (guardedDial); this is the cheap early gate.
func ValidatePublicDomain(domain string) error {
	if domain == "" {
		return fmt.Errorf("empty domain")
	}
	if strings.ContainsAny(domain, ":/?#@\\ ") {
		return fmt.Errorf("domain %q must be a bare hostname (no scheme, port, path or credentials)", domain)
	}
	if net.ParseIP(domain) != nil {
		return fmt.Errorf("domain %q must be a hostname, not an IP address", domain)
	}
	if !hostnameRe.MatchString(domain) {
		return fmt.Errorf("domain %q is not a valid public hostname", domain)
	}
	return nil
}

// CheckPublicHost validates that host is safe to make an outbound
// connection to, for callers other than federation discovery that also dial
// a user-supplied hostname (currently: one-shot repo import, see
// ROADMAP.md §3): a syntactically valid public hostname (ValidatePublicDomain)
// that currently resolves only to public addresses. Unlike guardedDial, this
// can't pin the resolved IP for the caller's own connection — the caller
// (e.g. a `git clone` subprocess) does its own DNS resolution afterwards, so
// this is a best-effort pre-flight gate against the DNS-rebinding window,
// not a hard guarantee the way guardedDial is for the federation HTTP client.
func CheckPublicHost(ctx context.Context, host string) error {
	if err := ValidatePublicDomain(host); err != nil {
		return err
	}
	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return fmt.Errorf("resolve %q: %w", host, err)
	}
	for _, ip := range ips {
		if isDisallowedIP(ip.IP) {
			return fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host)
		}
	}
	return nil
}

// guardedDial resolves the target host, refuses any non-public address, then
// dials the validated IP directly.
func guardedDial(ctx context.Context, network, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, err
	}
	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return nil, err
	}
	d := &net.Dialer{Timeout: 10 * time.Second}
	var lastErr error
	for _, ip := range ips {
		if isDisallowedIP(ip.IP) {
			lastErr = fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host)
			continue
		}
		conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
		if err == nil {
			return conn, nil
		}
		lastErr = err
	}
	if lastErr == nil {
		lastErr = fmt.Errorf("no address to dial for %s", host)
	}
	return nil, lastErr
}

// isDisallowedIP is true for any address that must never be the target of a
// federation fetch: loopback, RFC1918/ULA private, link-local (covers the
// 169.254.169.254 cloud-metadata endpoint), CGNAT, unspecified and multicast.
func isDisallowedIP(ip net.IP) bool {
	if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
		ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
		return true
	}
	// Carrier-grade NAT 100.64.0.0/10, not covered by IsPrivate.
	if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
		return true
	}
	return false
}

// Fetch retrieves and parses the well-known document for domain over HTTPS.
// insecure switches to plain HTTP and disables the anti-SSRF guards, for
// local development/testing only where there's no real DNS name or TLS cert
// (e.g. federating two instances both bound to 127.0.0.1) — never use it
// against a real remote instance.
func Fetch(domain string, insecure bool) (*WellKnown, error) {
	scheme, client := "https", secureClient
	if insecure {
		scheme, client = "http", insecureClient
	} else if err := ValidatePublicDomain(domain); err != nil {
		return nil, fmt.Errorf("federation: refusing to fetch %q: %w", domain, err)
	}

	url := fmt.Sprintf("%s://%s/.well-known/gitfed.json", scheme, domain)
	resp, err := client.Get(url)
	if err != nil {
		return nil, fmt.Errorf("federation: fetch %s: %w", url, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("federation: fetch %s: status %s", url, resp.Status)
	}

	var doc WellKnown
	if err := json.NewDecoder(io.LimitReader(resp.Body, maxWellKnownBytes)).Decode(&doc); err != nil {
		return nil, fmt.Errorf("federation: decode %s: %w", url, err)
	}
	if doc.Domain != domain {
		return nil, fmt.Errorf("federation: %s declares domain %q, expected %q", url, doc.Domain, domain)
	}
	if doc.CAPublicKey == "" {
		return nil, fmt.Errorf("federation: %s missing ca_public_key", url)
	}
	return &doc, nil
}