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 | } | |