Commit 7e3afe5f90
Verified · cmc
Layout: unified · split
internal/gitpin/gitpin.go added +127
| @@ -0,0 +1,127 @@ | |||
| 1 | // Package gitpin runs git against a user-supplied http or https remote | ||
| 2 | // only at addresses resolved and checked immediately before: mirror | ||
| 3 | // sync (#279) and repo import (#298). | ||
| 4 | package gitpin | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "context" | ||
| 8 | "fmt" | ||
| 9 | "net" | ||
| 10 | "net/url" | ||
| 11 | "os/exec" | ||
| 12 | "strconv" | ||
| 13 | "strings" | ||
| 14 | |||
| 15 | "gitbay.org/gitbay/internal/toolpath" | ||
| 16 | "gitbay.org/gitbay/internal/webhook" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // Lookup resolves a host to its addresses. | ||
| 20 | type Lookup func(ctx context.Context, host string) ([]net.IP, error) | ||
| 21 | |||
| 22 | // LookupIP is the system resolver. | ||
| 23 | func LookupIP(ctx context.Context, host string) ([]net.IP, error) { | ||
| 24 | return net.DefaultResolver.LookupIP(ctx, "ip", host) | ||
| 25 | } | ||
| 26 | |||
| 27 | // Remote is a URL whose host resolved to IPs, every one of which passed | ||
| 28 | // the address check. | ||
| 29 | type Remote struct { | ||
| 30 | URL *url.URL | ||
| 31 | IPs []net.IP | ||
| 32 | } | ||
| 33 | |||
| 34 | // Resolve parses raw, requires http or https, resolves the host with | ||
| 35 | // lookup, and refuses it when it resolves to nothing or, unless | ||
| 36 | // allowLocal, to any private or local address. | ||
| 37 | func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (Remote, error) { | ||
| 38 | u, err := url.Parse(raw) | ||
| 39 | if err != nil { | ||
| 40 | return Remote{}, err | ||
| 41 | } | ||
| 42 | if u.Scheme != "https" && u.Scheme != "http" { | ||
| 43 | return Remote{}, fmt.Errorf("URL scheme %q is not http or https", u.Scheme) | ||
| 44 | } | ||
| 45 | host := u.Hostname() | ||
| 46 | if host == "" { | ||
| 47 | return Remote{}, fmt.Errorf("URL has no host") | ||
| 48 | } | ||
| 49 | ips, err := lookup(ctx, host) | ||
| 50 | if err != nil { | ||
| 51 | return Remote{}, fmt.Errorf("resolving %s: %w", host, err) | ||
| 52 | } | ||
| 53 | if len(ips) == 0 { | ||
| 54 | // An empty resolve list would leave curl to resolve the host itself. | ||
| 55 | return Remote{}, fmt.Errorf("%s resolves to no address", host) | ||
| 56 | } | ||
| 57 | if err := webhook.CheckAddrs(host, ips, allowLocal); err != nil { | ||
| 58 | return Remote{}, err | ||
| 59 | } | ||
| 60 | return Remote{URL: u, IPs: ips}, nil | ||
| 61 | } | ||
| 62 | |||
| 63 | // 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 | ||
| 65 | // cannot send git on to a host nobody checked. An address literal | ||
| 66 | // needs no pin. | ||
| 67 | func (r Remote) Args() []string { | ||
| 68 | args := []string{"-c", "http.followRedirects=false"} | ||
| 69 | host := r.URL.Hostname() | ||
| 70 | if net.ParseIP(host) != nil { | ||
| 71 | return args | ||
| 72 | } | ||
| 73 | port := r.URL.Port() | ||
| 74 | if port == "" { | ||
| 75 | port = "443" | ||
| 76 | if r.URL.Scheme == "http" { | ||
| 77 | port = "80" | ||
| 78 | } | ||
| 79 | } | ||
| 80 | addrs := make([]string, len(r.IPs)) | ||
| 81 | for i, ip := range r.IPs { | ||
| 82 | if ip.To4() == nil { | ||
| 83 | addrs[i] = "[" + ip.String() + "]" | ||
| 84 | } else { | ||
| 85 | addrs[i] = ip.String() | ||
| 86 | } | ||
| 87 | } | ||
| 88 | return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ",")) | ||
| 89 | } | ||
| 90 | |||
| 91 | // Env is git's whole environment for a pinned remote. No system or | ||
| 92 | // global gitconfig: a proxy, URL rewrite or redirect setting there | ||
| 93 | // would take git around the pin. | ||
| 94 | func Env(home string) []string { | ||
| 95 | return []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + home, | ||
| 96 | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | ||
| 97 | } | ||
| 98 | |||
| 99 | // VersionOK accepts the output of `git version` for git 2.37 or later, | ||
| 100 | // the first release with http.curloptResolve. An older git ignores the | ||
| 101 | // setting and would resolve the host itself. | ||
| 102 | func VersionOK(out string) error { | ||
| 103 | fields := strings.Fields(out) | ||
| 104 | if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" { | ||
| 105 | parts := strings.Split(fields[2], ".") | ||
| 106 | if len(parts) >= 2 { | ||
| 107 | major, err1 := strconv.Atoi(parts[0]) | ||
| 108 | minor, err2 := strconv.Atoi(parts[1]) | ||
| 109 | if err1 == nil && err2 == nil { | ||
| 110 | if major > 2 || major == 2 && minor >= 37 { | ||
| 111 | return nil | ||
| 112 | } | ||
| 113 | return fmt.Errorf("git %s is older than 2.37 and cannot pin remote addresses", fields[2]) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | return fmt.Errorf("cannot read git version from %q", strings.TrimSpace(out)) | ||
| 118 | } | ||
| 119 | |||
| 120 | // CheckGit runs the server's git and refuses one that cannot pin. | ||
| 121 | func CheckGit(ctx context.Context) error { | ||
| 122 | out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output() | ||
| 123 | if err != nil { | ||
| 124 | return fmt.Errorf("running git version: %v", err) | ||
| 125 | } | ||
| 126 | return VersionOK(string(out)) | ||
| 127 | } | ||
internal/gitpin/gitpin_test.go added +88
| @@ -0,0 +1,88 @@ | |||
| 1 | package gitpin | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "net" | ||
| 6 | "net/url" | ||
| 7 | "slices" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func answer(ips ...string) Lookup { | ||
| 13 | return func(context.Context, string) ([]net.IP, error) { | ||
| 14 | var out []net.IP | ||
| 15 | for _, s := range ips { | ||
| 16 | out = append(out, net.ParseIP(s)) | ||
| 17 | } | ||
| 18 | return out, nil | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | func TestResolve(t *testing.T) { | ||
| 23 | ctx := context.Background() | ||
| 24 | r, err := Resolve(ctx, answer("203.0.113.5"), "https://git.example/x.git", false) | ||
| 25 | if err != nil || r.URL.Hostname() != "git.example" || len(r.IPs) != 1 { | ||
| 26 | t.Fatalf("public: %+v %v", r, err) | ||
| 27 | } | ||
| 28 | if _, err := Resolve(ctx, answer("203.0.113.5", "10.0.0.7"), "https://git.example/x.git", false); err == nil || !strings.Contains(err.Error(), "10.0.0.7") { | ||
| 29 | t.Fatalf("private: %v", err) | ||
| 30 | } | ||
| 31 | if _, err := Resolve(ctx, answer("10.0.0.7"), "https://git.example/x.git", true); err != nil { | ||
| 32 | t.Fatalf("allow_local: %v", err) | ||
| 33 | } | ||
| 34 | // An empty resolve list would leave curl to resolve the host itself. | ||
| 35 | if _, err := Resolve(ctx, answer(), "https://git.example/x.git", true); err == nil || !strings.Contains(err.Error(), "no address") { | ||
| 36 | t.Fatalf("empty answer: %v", err) | ||
| 37 | } | ||
| 38 | for _, raw := range []string{"git://git.example/x.git", "ssh://git.example/x.git", "file:///etc"} { | ||
| 39 | _, err := Resolve(ctx, func(context.Context, string) ([]net.IP, error) { | ||
| 40 | t.Fatalf("looked up a host for %s", raw) | ||
| 41 | return nil, nil | ||
| 42 | }, raw, true) | ||
| 43 | if err == nil || !strings.Contains(err.Error(), "not http or https") { | ||
| 44 | t.Errorf("%s: %v", raw, err) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | func TestArgs(t *testing.T) { | ||
| 50 | 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() | ||
| 52 | want := []string{"-c", "http.followRedirects=false", | ||
| 53 | "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"} | ||
| 54 | if !slices.Equal(got, want) { | ||
| 55 | t.Fatalf("https: %q", got) | ||
| 56 | } | ||
| 57 | 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" { | ||
| 59 | t.Fatalf("http with port: %q", got) | ||
| 60 | } | ||
| 61 | // An address literal is its own resolution; there is nothing to pin. | ||
| 62 | u, _ = url.Parse("https://203.0.113.5/x.git") | ||
| 63 | if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) { | ||
| 64 | t.Fatalf("literal: %q", got) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestEnv(t *testing.T) { | ||
| 69 | want := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/srv/gitbay", | ||
| 70 | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | ||
| 71 | if got := Env("/srv/gitbay"); !slices.Equal(got, want) { | ||
| 72 | t.Fatalf("Env = %q", got) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | func TestVersionOK(t *testing.T) { | ||
| 77 | for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)", | ||
| 78 | "git version 2.45.2.windows.1", "git version 3.0.0\n"} { | ||
| 79 | if err := VersionOK(s); err != nil { | ||
| 80 | t.Errorf("%q: %v", s, err) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} { | ||
| 84 | if err := VersionOK(s); err == nil { | ||
| 85 | t.Errorf("%q accepted", s) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||