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}