internal/gitpin/gitpin.go

182 lines · 5821 bytes

11 symbols in this file
  1// Package gitpin runs git against a user-supplied http or https remote
  2// only at addresses resolved and checked immediately before: mirror
  3// sync (#279), repo import (#298) and repo import-issues (#301), whose
  4// API client dials the same way.
  5package gitpin
  6
  7import (
  8	"context"
  9	"fmt"
 10	"net"
 11	"net/url"
 12	"os/exec"
 13	"strconv"
 14	"strings"
 15	"time"
 16
 17	"gitbay.org/gitbay/internal/toolpath"
 18	"gitbay.org/gitbay/internal/webhook"
 19)
 20
 21// Lookup resolves a host to its addresses.
 22type Lookup func(ctx context.Context, host string) ([]net.IP, error)
 23
 24// LookupIP is the system resolver.
 25func LookupIP(ctx context.Context, host string) ([]net.IP, error) {
 26	return net.DefaultResolver.LookupIP(ctx, "ip", host)
 27}
 28
 29// Remote is a URL whose host resolved to IPs, every one of which passed
 30// the address check.
 31type Remote struct {
 32	URL *url.URL
 33	IPs []net.IP
 34}
 35
 36// Resolve parses raw, requires http or https, resolves the host with
 37// lookup, and refuses it when it resolves to nothing or, unless
 38// allowLocal, to any private or local address.
 39func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (Remote, error) {
 40	u, err := url.Parse(raw)
 41	if err != nil {
 42		return Remote{}, err
 43	}
 44	if u.Scheme != "https" && u.Scheme != "http" {
 45		return Remote{}, fmt.Errorf("URL scheme %q is not http or https", u.Scheme)
 46	}
 47	host := u.Hostname()
 48	if host == "" {
 49		return Remote{}, fmt.Errorf("URL has no host")
 50	}
 51	if err := CheckHost(host); err != nil {
 52		return Remote{}, err
 53	}
 54	ips, err := lookup(ctx, host)
 55	if err != nil {
 56		return Remote{}, fmt.Errorf("resolving %s: %w", host, err)
 57	}
 58	if len(ips) == 0 {
 59		// An empty resolve list would leave curl to resolve the host itself.
 60		return Remote{}, fmt.Errorf("%s resolves to no address", host)
 61	}
 62	if err := webhook.CheckAddrs(host, ips, allowLocal); err != nil {
 63		return Remote{}, err
 64	}
 65	return Remote{URL: u, IPs: ips}, nil
 66}
 67
 68// DialContext connects to r's checked addresses, trying each in turn,
 69// whatever host addr names; only its port is used. An HTTP client
 70// built on it must not follow a redirect to another host.
 71func (r Remote) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
 72	_, port, err := net.SplitHostPort(addr)
 73	if err != nil {
 74		return nil, err
 75	}
 76	d := net.Dialer{Timeout: 10 * time.Second}
 77	for _, ip := range r.IPs {
 78		var conn net.Conn
 79		conn, err = d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
 80		if err == nil {
 81			return conn, nil
 82		}
 83	}
 84	return nil, err
 85}
 86
 87// CheckHost refuses a host written as a number in a form other than
 88// an IP literal: 127.1, 2130706433 and 0x7f.1 are loopback to curl's
 89// parser but not to Go's, so they are refused rather than left to a
 90// resolver.
 91func CheckHost(host string) error {
 92	if net.ParseIP(host) == nil && numericHost(host) {
 93		return fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host)
 94	}
 95	return nil
 96}
 97
 98// numericHost reports whether every label of host is a decimal, octal
 99// or hex number, the shapes inet_aton reads as an IPv4 address.
100func numericHost(host string) bool {
101	for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") {
102		digits, base := label, "0123456789"
103		if rest, ok := strings.CutPrefix(strings.ToLower(label), "0x"); ok {
104			digits, base = rest, "0123456789abcdef"
105		}
106		if strings.Trim(strings.ToLower(digits), base) != "" || label == "" {
107			return false
108		}
109	}
110	return true
111}
112
113// Args are git's leading -c options for r: curl's resolve list pins
114// the host, and any other name on the same port, to the checked
115// addresses, and with redirects off a server
116// cannot send git on to a host nobody checked. An address literal
117// needs no pin.
118func (r Remote) Args() []string {
119	args := []string{"-c", "http.followRedirects=false"}
120	host := r.URL.Hostname()
121	if net.ParseIP(host) != nil {
122		return args
123	}
124	port := r.URL.Port()
125	if port == "" {
126		port = "443"
127		if r.URL.Scheme == "http" {
128			port = "80"
129		}
130	}
131	addrs := make([]string, len(r.IPs))
132	for i, ip := range r.IPs {
133		if ip.To4() == nil {
134			addrs[i] = "[" + ip.String() + "]"
135		} else {
136			addrs[i] = ip.String()
137		}
138	}
139	pinned := port + ":" + strings.Join(addrs, ",")
140	// The wildcard entry catches a lookup under any other spelling of
141	// the host, so it too lands on the checked addresses.
142	return append(args, "-c", "http.curloptResolve="+host+":"+pinned,
143		"-c", "http.curloptResolve=*:"+pinned)
144}
145
146// Env is git's whole environment for a pinned remote. No system or
147// global gitconfig: a proxy, URL rewrite or redirect setting there
148// would take git around the pin.
149func Env(home string) []string {
150	return []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + home,
151		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
152}
153
154// VersionOK accepts the output of `git version` for git 2.37 or later,
155// the first release with http.curloptResolve. An older git ignores the
156// setting and would resolve the host itself.
157func VersionOK(out string) error {
158	fields := strings.Fields(out)
159	if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" {
160		parts := strings.Split(fields[2], ".")
161		if len(parts) >= 2 {
162			major, err1 := strconv.Atoi(parts[0])
163			minor, err2 := strconv.Atoi(parts[1])
164			if err1 == nil && err2 == nil {
165				if major > 2 || major == 2 && minor >= 37 {
166					return nil
167				}
168				return fmt.Errorf("git %s is older than 2.37 and cannot pin remote addresses", fields[2])
169			}
170		}
171	}
172	return fmt.Errorf("cannot read git version from %q", strings.TrimSpace(out))
173}
174
175// CheckGit runs the server's git and refuses one that cannot pin.
176func CheckGit(ctx context.Context) error {
177	out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output()
178	if err != nil {
179		return fmt.Errorf("running git version: %v", err)
180	}
181	return VersionOK(string(out))
182}