Commit c32afe91e2

c32afe91e21cc75d0dff46452074510659b51ac0

parent: 1c6440454b

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:23 UTC

gitpin: refuse odd numeric hosts, pin every name on the port

Ref #298

Layout: unified · split

internal/gitpin/gitpin.go +27 −2
@@ -46,6 +46,11 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R
46 if host == "" { 46 if host == "" {
47 return Remote{}, fmt.Errorf("URL has no host") 47 return Remote{}, fmt.Errorf("URL has no host")
48 } 48 }
49 if net.ParseIP(host) == nil && numericHost(host) {
50 // 127.1, 2130706433 and 0x7f.1 are loopback to curl's parser
51 // but not to Go's; refuse rather than leave them to a resolver.
52 return Remote{}, fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host)
53 }
49 ips, err := lookup(ctx, host) 54 ips, err := lookup(ctx, host)
50 if err != nil { 55 if err != nil {
51 return Remote{}, fmt.Errorf("resolving %s: %w", host, err) 56 return Remote{}, fmt.Errorf("resolving %s: %w", host, err)
@@ -60,8 +65,24 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R
60 return Remote{URL: u, IPs: ips}, nil 65 return Remote{URL: u, IPs: ips}, nil
61} 66}
62 67
68// numericHost reports whether every label of host is a decimal, octal
69// or hex number, the shapes inet_aton reads as an IPv4 address.
70func numericHost(host string) bool {
71 for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") {
72 digits, base := label, "0123456789"
73 if rest, ok := strings.CutPrefix(strings.ToLower(label), "0x"); ok {
74 digits, base = rest, "0123456789abcdef"
75 }
76 if strings.Trim(strings.ToLower(digits), base) != "" || label == "" {
77 return false
78 }
79 }
80 return true
81}
82
63// Args are git's leading -c options for r: curl's resolve list pins 83// Args are git's leading -c options for r: curl's resolve list pins
64// the host to the checked addresses, and with redirects off a server 84// the host, and any other name on the same port, to the checked
85// addresses, and with redirects off a server
65// cannot send git on to a host nobody checked. An address literal 86// cannot send git on to a host nobody checked. An address literal
66// needs no pin. 87// needs no pin.
67func (r Remote) Args() []string { 88func (r Remote) Args() []string {
@@ -85,7 +106,11 @@ func (r Remote) Args() []string {
85 addrs[i] = ip.String() 106 addrs[i] = ip.String()
86 } 107 }
87 } 108 }
88 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ",")) 109 pinned := port + ":" + strings.Join(addrs, ",")
110 // The wildcard entry catches a lookup under any other spelling of
111 // the host, so it too lands on the checked addresses.
112 return append(args, "-c", "http.curloptResolve="+host+":"+pinned,
113 "-c", "http.curloptResolve=*:"+pinned)
89} 114}
90 115
91// Env is git's whole environment for a pinned remote. No system or 116// Env is git's whole environment for a pinned remote. No system or
internal/gitpin/gitpin_test.go +24 −2
@@ -46,16 +46,38 @@ func TestResolve(t *testing.T) {
46 } 46 }
47} 47}
48 48
49// Numeric hosts other than a dotted quad are refused before any lookup:
50// curl reads them as addresses the check never saw.
51func TestResolveRefusesOddNumericHosts(t *testing.T) {
52 never := func(_ context.Context, host string) ([]net.IP, error) {
53 t.Fatalf("looked up %s", host)
54 return nil, nil
55 }
56 for _, host := range []string{"127.1", "2130706433", "0x7f.1", "0x7F000001", "017700000001", "127.0.0.01", "127.0.0.1."} {
57 if _, err := Resolve(context.Background(), never, "http://"+host+"/x.git", true); err == nil || !strings.Contains(err.Error(), "numeric address") {
58 t.Errorf("%s: %v", host, err)
59 }
60 }
61 // Names with a numeric label, and real literals, still pass.
62 for _, host := range []string{"1.example", "0x7f.example", "203.0.113.5", "[2001:db8::1]"} {
63 if _, err := Resolve(context.Background(), answer("203.0.113.5"), "http://"+host+"/x.git", false); err != nil {
64 t.Errorf("%s: %v", host, err)
65 }
66 }
67}
68
49func TestArgs(t *testing.T) { 69func TestArgs(t *testing.T) {
50 u, _ := url.Parse("https://git.example/x.git") 70 u, _ := url.Parse("https://git.example/x.git")
51 got := Remote{u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}}.Args() 71 got := Remote{u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}}.Args()
52 want := []string{"-c", "http.followRedirects=false", 72 want := []string{"-c", "http.followRedirects=false",
53 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"} 73 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]",
74 "-c", "http.curloptResolve=*:443:203.0.113.5,[2001:db8::1]"}
54 if !slices.Equal(got, want) { 75 if !slices.Equal(got, want) {
55 t.Fatalf("https: %q", got) 76 t.Fatalf("https: %q", got)
56 } 77 }
57 u, _ = url.Parse("http://git.example:8080/x.git") 78 u, _ = url.Parse("http://git.example:8080/x.git")
58 if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" { 79 if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" ||
80 got[5] != "http.curloptResolve=*:8080:203.0.113.5" {
59 t.Fatalf("http with port: %q", got) 81 t.Fatalf("http with port: %q", got)
60 } 82 }
61 // An address literal is its own resolution; there is nothing to pin. 83 // An address literal is its own resolution; there is nothing to pin.