internal/gitpin/gitpin.go
182 lines · 5821 bytes
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}