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}