Parent directory

git.go

8787 bytes
  1package ssrf
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"net"
  8	"net/netip"
  9	"net/url"
 10	"strconv"
 11	"strings"
 12)
 13
 14var (
 15	// ErrUnsupportedRemoteScheme is returned when a git remote uses a scheme
 16	// that is neither a supported network transport nor safe to hand to the
 17	// git subprocess (e.g. file://, ext::, or a bare local path).
 18	ErrUnsupportedRemoteScheme = errors.New("remote scheme is not allowed")
 19	// ErrAmbiguousHost is returned when a remote host is neither a canonical
 20	// IP literal nor a valid DNS name. Non-canonical IPv4 literals such as
 21	// "0177.0.0.1" land here: Go reads them as one address and libcurl reads
 22	// them as another, so they can never be validated safely.
 23	ErrAmbiguousHost = errors.New("remote host is not a canonical IP or DNS name")
 24)
 25
 26// GitRemoteTransport describes how a validated remote will be reached.
 27type GitRemoteTransport int
 28
 29const (
 30	// GitTransportHTTP is an http/https remote. These are validated and
 31	// pinned to the resolved IP.
 32	GitTransportHTTP GitRemoteTransport = iota
 33	// GitTransportGit is a git:// remote. These are validated but cannot be
 34	// pinned, since the git protocol dials directly rather than via libcurl.
 35	GitTransportGit
 36	// GitTransportSSH is an ssh remote. Reachability is governed by the
 37	// operator's SSH client key, so these are not IP-validated.
 38	GitTransportSSH
 39)
 40
 41// GitConfigEntry is a single git configuration key/value pair.
 42type GitConfigEntry struct {
 43	Key   string
 44	Value string
 45}
 46
 47// ValidatedGitRemote is the result of validating a git remote URL.
 48type ValidatedGitRemote struct {
 49	// Transport is the transport the remote will use.
 50	Transport GitRemoteTransport
 51	// Config holds per-remote git configuration required for the validation
 52	// to hold, such as pinning a hostname to the address that was validated.
 53	// Render it with GitEnv, which also applies the settings that are needed
 54	// regardless of remote.
 55	Config []GitConfigEntry
 56}
 57
 58// GitEnv renders the environment that must be passed to any git subprocess
 59// touching the given validated remotes.
 60//
 61// Validation alone is not sufficient for a subprocess: git resolves hostnames
 62// again itself, and follows the first HTTP redirect by default. Both walk
 63// straight through a validate-then-exec check, so this environment disables
 64// redirects and pins each validated address. Callers MUST apply it to every
 65// git command that touches the remote, or the validation means nothing.
 66func GitEnv(remotes ...ValidatedGitRemote) []string {
 67	// git follows the first HTTP redirect by default, which lets a public URL
 68	// hand off to an internal one after validation has already passed. This
 69	// costs imports of moved repositories, which is the right trade for a
 70	// user-supplied remote.
 71	entries := []GitConfigEntry{{Key: "http.followRedirects", Value: "false"}}
 72	for _, r := range remotes {
 73		entries = append(entries, r.Config...)
 74	}
 75
 76	env := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(entries))}
 77	for i, e := range entries {
 78		env = append(env,
 79			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, e.Key),
 80			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, e.Value),
 81		)
 82	}
 83	return env
 84}
 85
 86// ValidateGitRemote validates a git remote URL against private, internal, and
 87// loopback address ranges.
 88//
 89// The result must be passed to GitEnv, and that environment applied to every
 90// git subprocess touching the remote. Validation on its own does not survive
 91// contact with git, which re-resolves hostnames and follows redirects.
 92//
 93// SSH remotes are allowed without IP validation: they authenticate with the
 94// operator's own client key rather than anything an importing user controls.
 95func ValidateGitRemote(remote string) (ValidatedGitRemote, error) {
 96	if remote == "" {
 97		return ValidatedGitRemote{}, ErrInvalidURL
 98	}
 99
100	u, err := url.Parse(remote)
101	if err != nil {
102		// Bare SSH syntax (git@host:path) is not a parseable URL. Anything
103		// else that fails to parse is rejected rather than passed through.
104		if isBareSSHRemote(remote) {
105			return ValidatedGitRemote{Transport: GitTransportSSH}, nil
106		}
107		return ValidatedGitRemote{}, fmt.Errorf("%w: %v", ErrInvalidURL, err)
108	}
109
110	switch u.Scheme {
111	case "ssh", "git+ssh", "ssh+git":
112		return ValidatedGitRemote{Transport: GitTransportSSH}, nil
113
114	case "git":
115		// git:// is a plain TCP connect to an arbitrary host:port, so it is
116		// as much an SSRF primitive as http and gets the same validation.
117		// It cannot be pinned, since libcurl is not involved.
118		if _, err := validateRemoteHost(u.Hostname()); err != nil {
119			return ValidatedGitRemote{}, err
120		}
121		return ValidatedGitRemote{Transport: GitTransportGit}, nil
122
123	case "http", "https":
124		addr, err := validateRemoteHost(u.Hostname())
125		if err != nil {
126			return ValidatedGitRemote{}, err
127		}
128		// Pin the validated address so git's own resolution cannot return a
129		// different (private) one than the address we just checked.
130		return ValidatedGitRemote{
131			Transport: GitTransportHTTP,
132			Config:    curlResolvePin(u, addr),
133		}, nil
134
135	default:
136		// file://, ext::, and bare local paths land here. None are network
137		// remotes, and ext:: is arbitrary command execution.
138		return ValidatedGitRemote{}, fmt.Errorf("%w: %q", ErrUnsupportedRemoteScheme, u.Scheme)
139	}
140}
141
142// validateRemoteHost checks a remote host against private and internal ranges,
143// returning the validated address so callers can pin it.
144func validateRemoteHost(host string) (netip.Addr, error) {
145	if host == "" {
146		return netip.Addr{}, fmt.Errorf("%w: missing hostname", ErrInvalidURL)
147	}
148
149	if isLocalhost(host) {
150		return netip.Addr{}, ErrPrivateIP
151	}
152
153	// netip.ParseAddr accepts only canonical forms, so non-canonical IPv4
154	// literals (octal, hex, short-form) fall through to the DNS name check
155	// below and are rejected there rather than being resolved.
156	if addr, err := netip.ParseAddr(host); err == nil {
157		if isPrivateOrInternal(net.IP(addr.AsSlice())) {
158			return netip.Addr{}, ErrPrivateIP
159		}
160		return addr, nil
161	}
162
163	if !isDNSName(host) {
164		return netip.Addr{}, fmt.Errorf("%w: %q", ErrAmbiguousHost, host)
165	}
166
167	ips, err := net.DefaultResolver.LookupIPAddr(context.Background(), host)
168	if err != nil {
169		return netip.Addr{}, fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err)
170	}
171	if len(ips) == 0 {
172		return netip.Addr{}, fmt.Errorf("%w: no addresses for hostname", ErrInvalidURL)
173	}
174
175	// Every resolved address must be public. A hostname answering with both
176	// a public and a private address is a rebinding attempt, not a remote.
177	for _, ip := range ips {
178		if isPrivateOrInternal(ip.IP) {
179			return netip.Addr{}, ErrPrivateIP
180		}
181	}
182
183	addr, ok := netip.AddrFromSlice(ips[0].IP)
184	if !ok {
185		return netip.Addr{}, fmt.Errorf("%w: unusable address for hostname", ErrInvalidURL)
186	}
187	return addr.Unmap(), nil
188}
189
190// isDNSName reports whether a host is a valid DNS name. The final label must
191// not be all-numeric (RFC 1123 section 2.1), which is what separates a real
192// hostname from an IPv4 literal that netip rejected as non-canonical.
193func isDNSName(host string) bool {
194	host = strings.TrimSuffix(host, ".")
195	if host == "" || len(host) > 253 {
196		return false
197	}
198
199	labels := strings.Split(host, ".")
200	for _, label := range labels {
201		if label == "" || len(label) > 63 {
202			return false
203		}
204		if label[0] == '-' || label[len(label)-1] == '-' {
205			return false
206		}
207		for i := range label {
208			c := label[i]
209			isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
210			if !isAlnum && c != '-' && c != '_' {
211				return false
212			}
213		}
214	}
215
216	// An all-numeric final label means this was meant as an IP address. Since
217	// netip already rejected it as non-canonical, refuse rather than guess.
218	last := labels[len(labels)-1]
219	if _, err := strconv.ParseUint(last, 0, 64); err == nil {
220		return false
221	}
222
223	return true
224}
225
226// curlResolvePin returns git configuration pinning the URL's host to the
227// validated address, or nothing if the URL needs no pinning.
228func curlResolvePin(u *url.URL, addr netip.Addr) []GitConfigEntry {
229	// An IP literal is already pinned by construction.
230	if !addr.IsValid() || isIPLiteral(u.Hostname()) {
231		return nil
232	}
233
234	port := u.Port()
235	if port == "" {
236		port = "80"
237		if u.Scheme == "https" {
238			port = "443"
239		}
240	}
241
242	return []GitConfigEntry{{
243		Key:   "http.curloptResolve",
244		Value: fmt.Sprintf("%s:%s:%s", u.Hostname(), port, addr.String()),
245	}}
246}
247
248// isIPLiteral reports whether a host is already a canonical IP address.
249func isIPLiteral(host string) bool {
250	_, err := netip.ParseAddr(host)
251	return err == nil
252}
253
254// isBareSSHRemote reports whether a remote uses scp-like syntax
255// (user@host:path), which is an SSH remote rather than a malformed URL.
256func isBareSSHRemote(remote string) bool {
257	at := strings.Index(remote, "@")
258	if at <= 0 {
259		return false
260	}
261	return strings.Contains(remote[at:], ":")
262}