Parent directory

git_test.go

8694 bytes
  1package ssrf
  2
  3import (
  4	"errors"
  5	"net/netip"
  6	"net/url"
  7	"slices"
  8	"strconv"
  9	"strings"
 10	"testing"
 11)
 12
 13func TestValidateGitRemote(t *testing.T) {
 14	tests := []struct {
 15		name      string
 16		remote    string
 17		transport GitRemoteTransport
 18		wantErr   error
 19	}{
 20		// Allowed network remotes. IP literals only, so the suite does not
 21		// depend on DNS. Hostname resolution is covered separately.
 22		{"public http", "http://1.1.1.1/x.git", GitTransportHTTP, nil},
 23		{"public https", "https://1.1.1.1/x.git", GitTransportHTTP, nil},
 24		{"public https with port", "https://1.1.1.1:8443/x.git", GitTransportHTTP, nil},
 25		{"public ipv6", "https://[2606:4700::1111]/x.git", GitTransportHTTP, nil},
 26
 27		// SSH is reachability-governed by the operator's client key.
 28		{"ssh url", "ssh://git@10.0.0.1/x.git", GitTransportSSH, nil},
 29		{"bare ssh", "git@github.com:charmbracelet/soft-serve.git", GitTransportSSH, nil},
 30		{"git+ssh", "git+ssh://git@example.com/x.git", GitTransportSSH, nil},
 31		{"ssh+git", "ssh+git://git@example.com/x.git", GitTransportSSH, nil},
 32
 33		// Loopback and private ranges over http.
 34		{"loopback", "http://127.0.0.1/x.git", 0, ErrPrivateIP},
 35		{"localhost", "http://localhost:8080/x.git", 0, ErrPrivateIP},
 36		{"private 10.x", "http://10.0.0.1/x.git", 0, ErrPrivateIP},
 37		{"private 192.168.x", "http://192.168.1.1/x.git", 0, ErrPrivateIP},
 38		{"cloud metadata", "http://169.254.169.254/latest/meta-data/", 0, ErrPrivateIP},
 39		{"ipv6 loopback", "http://[::1]/x.git", 0, ErrPrivateIP},
 40		{"ipv4-mapped loopback", "http://[::ffff:127.0.0.1]/x.git", 0, ErrPrivateIP},
 41
 42		// git:// is a raw TCP connect and must be validated the same way.
 43		// Regression: an earlier fix checked http(s) on import but let
 44		// git:// through on mirror sync.
 45		{"git scheme private", "git://10.0.0.1/x.git", 0, ErrPrivateIP},
 46		{"git scheme metadata", "git://169.254.169.254/x.git", 0, ErrPrivateIP},
 47		{"git scheme public", "git://1.1.1.1/x.git", GitTransportGit, nil},
 48
 49		// Non-canonical IPv4 literals parse differently in Go and libcurl.
 50		// Regression: net.ParseIP reads 0177.0.0.1 as public 177.0.0.1 while
 51		// libcurl reads it as loopback.
 52		{"octal loopback", "http://0177.0.0.1/x.git", 0, ErrAmbiguousHost},
 53		{"hex loopback", "http://0x7f.1/x.git", 0, ErrAmbiguousHost},
 54		{"short-form loopback", "http://127.1/x.git", 0, ErrAmbiguousHost},
 55		{"decimal loopback", "http://2130706433/x.git", 0, ErrAmbiguousHost},
 56		{"octal private", "http://010.0.0.1/x.git", 0, ErrAmbiguousHost},
 57
 58		// Non-network schemes are not remotes at all.
 59		{"file scheme", "file:///etc/passwd", 0, ErrUnsupportedRemoteScheme},
 60		{"ext scheme", "ext::sh -c whoami", 0, ErrUnsupportedRemoteScheme},
 61		{"local path", "/data/repos/secret.git", 0, ErrUnsupportedRemoteScheme},
 62
 63		{"empty", "", 0, ErrInvalidURL},
 64		{"no host", "https:///x.git", 0, ErrInvalidURL},
 65	}
 66
 67	for _, tt := range tests {
 68		t.Run(tt.name, func(t *testing.T) {
 69			got, err := ValidateGitRemote(tt.remote)
 70
 71			if tt.wantErr != nil {
 72				if !errors.Is(err, tt.wantErr) {
 73					t.Fatalf("ValidateGitRemote(%q) error = %v, want %v", tt.remote, err, tt.wantErr)
 74				}
 75				return
 76			}
 77
 78			if err != nil {
 79				t.Fatalf("ValidateGitRemote(%q) unexpected error: %v", tt.remote, err)
 80			}
 81			if got.Transport != tt.transport {
 82				t.Errorf("ValidateGitRemote(%q) transport = %v, want %v", tt.remote, got.Transport, tt.transport)
 83			}
 84		})
 85	}
 86}
 87
 88// TestValidateGitRemoteResolvesHostnames covers the DNS path, which the main
 89// table deliberately avoids so it can run offline.
 90func TestValidateGitRemoteResolvesHostnames(t *testing.T) {
 91	if testing.Short() {
 92		t.Skip("requires DNS")
 93	}
 94
 95	v, err := ValidateGitRemote("https://github.com/charmbracelet/soft-serve.git")
 96	if err != nil {
 97		t.Fatalf("public hostname rejected: %v", err)
 98	}
 99	if v.Transport != GitTransportHTTP {
100		t.Errorf("transport = %v, want %v", v.Transport, GitTransportHTTP)
101	}
102	// A resolved hostname must be pinned, or git re-resolves it.
103	if len(v.Config) == 0 {
104		t.Error("resolved hostname was not pinned")
105	}
106}
107
108// TestGitEnvDisablesRedirects covers the bypass where a validated public URL
109// 302s to an internal address. git follows the first redirect by default, so
110// validation is meaningless without this.
111func TestGitEnvDisablesRedirects(t *testing.T) {
112	for _, name := range []string{"no remotes", "one remote"} {
113		t.Run(name, func(t *testing.T) {
114			var env []string
115			if name == "no remotes" {
116				env = GitEnv()
117			} else {
118				env = GitEnv(ValidatedGitRemote{Transport: GitTransportHTTP})
119			}
120			if !hasGitConfig(env, "http.followRedirects", "false") {
121				t.Errorf("GitEnv did not disable redirects: %v", env)
122			}
123		})
124	}
125}
126
127// TestCurlResolvePin covers DNS rebinding: git re-resolves the hostname
128// itself, so the validated address has to be pinned for the check to survive
129// into the subprocess.
130func TestCurlResolvePin(t *testing.T) {
131	tests := []struct {
132		name string
133		url  string
134		addr string
135		want string
136	}{
137		{"https default port", "https://example.com/x.git", "1.1.1.1", "example.com:443:1.1.1.1"},
138		{"http default port", "http://example.com/x.git", "1.1.1.1", "example.com:80:1.1.1.1"},
139		{"explicit port", "https://example.com:8443/x.git", "1.1.1.1", "example.com:8443:1.1.1.1"},
140		{"ipv6 address", "https://example.com/x.git", "2606:4700::1111", "example.com:443:2606:4700::1111"},
141		// An IP literal is pinned by construction; nothing to re-resolve.
142		{"ip literal needs no pin", "https://1.1.1.1/x.git", "1.1.1.1", ""},
143	}
144
145	for _, tt := range tests {
146		t.Run(tt.name, func(t *testing.T) {
147			u, err := url.Parse(tt.url)
148			if err != nil {
149				t.Fatalf("bad test url: %v", err)
150			}
151
152			got := curlResolvePin(u, netip.MustParseAddr(tt.addr))
153
154			if tt.want == "" {
155				if len(got) != 0 {
156					t.Fatalf("curlResolvePin(%q) = %v, want none", tt.url, got)
157				}
158				return
159			}
160
161			if len(got) != 1 {
162				t.Fatalf("curlResolvePin(%q) = %v, want 1 entry", tt.url, got)
163			}
164			if got[0].Key != "http.curloptResolve" {
165				t.Errorf("key = %q, want http.curloptResolve", got[0].Key)
166			}
167			if got[0].Value != tt.want {
168				t.Errorf("value = %q, want %q", got[0].Value, tt.want)
169			}
170		})
171	}
172}
173
174// TestGitEnvCountMatchesEntries guards the encoding itself. git reads exactly
175// GIT_CONFIG_COUNT entries, so a miscount silently drops the security settings
176// rather than failing loudly.
177func TestGitEnvCountMatchesEntries(t *testing.T) {
178	pinned := ValidatedGitRemote{
179		Transport: GitTransportHTTP,
180		Config: []GitConfigEntry{{
181			Key:   "http.curloptResolve",
182			Value: "example.com:443:1.1.1.1",
183		}},
184	}
185
186	for _, tc := range []struct {
187		name    string
188		remotes []ValidatedGitRemote
189	}{
190		{"none", nil},
191		{"unpinned", []ValidatedGitRemote{{Transport: GitTransportSSH}}},
192		{"one pinned", []ValidatedGitRemote{pinned}},
193		{"multiple pinned", []ValidatedGitRemote{pinned, pinned}},
194	} {
195		t.Run(tc.name, func(t *testing.T) {
196			env := GitEnv(tc.remotes...)
197
198			var count string
199			keys := 0
200			for _, e := range env {
201				switch {
202				case strings.HasPrefix(e, "GIT_CONFIG_COUNT="):
203					count = strings.TrimPrefix(e, "GIT_CONFIG_COUNT=")
204				case strings.HasPrefix(e, "GIT_CONFIG_KEY_"):
205					keys++
206				}
207			}
208
209			if want := strconv.Itoa(keys); count != want {
210				t.Errorf("GIT_CONFIG_COUNT = %q, want %q (env: %v)", count, want, env)
211			}
212			// Every key must have a matching value at the same index.
213			for i := range keys {
214				if !slices.ContainsFunc(env, func(e string) bool {
215					return strings.HasPrefix(e, "GIT_CONFIG_VALUE_"+strconv.Itoa(i)+"=")
216				}) {
217					t.Errorf("GIT_CONFIG_KEY_%d has no matching value: %v", i, env)
218				}
219			}
220		})
221	}
222}
223
224func TestIsDNSName(t *testing.T) {
225	tests := []struct {
226		host string
227		want bool
228	}{
229		{"example.com", true},
230		{"sub.example.com", true},
231		{"example.com.", true},
232		{"my-host", true},
233
234		// All-numeric final label means an IP was intended. netip already
235		// rejected these as non-canonical, so they must not be resolved.
236		{"0177.0.0.1", false},
237		{"127.1", false},
238		{"2130706433", false},
239		{"0x7f.1", false},
240
241		{"", false},
242		{"-leading.com", false},
243		{"trailing-.com", false},
244		{"has space.com", false},
245		{"double..dot", false},
246	}
247
248	for _, tt := range tests {
249		t.Run(tt.host, func(t *testing.T) {
250			if got := isDNSName(tt.host); got != tt.want {
251				t.Errorf("isDNSName(%q) = %v, want %v", tt.host, got, tt.want)
252			}
253		})
254	}
255}
256
257func hasGitConfig(env []string, key, value string) bool {
258	for _, e := range env {
259		name, v, ok := strings.Cut(e, "=")
260		if !ok || !strings.HasPrefix(name, "GIT_CONFIG_KEY_") || v != key {
261			continue
262		}
263		idx := strings.TrimPrefix(name, "GIT_CONFIG_KEY_")
264		if slices.Contains(env, "GIT_CONFIG_VALUE_"+idx+"="+value) {
265			return true
266		}
267	}
268	return false
269}