import: http(s) only, checked and pinned like mirrors !512

merged merged by cmc on 2026-09-29 02:21 UTC · krz/gitbay:import-pin-address into main

15 files changed, +504 −173

Layout: unified · split

.gitbay/wiki/Admin.org +4 −2
@@ -193,12 +193,14 @@ push=.
193 means no credential-bearing HTTP endpoint exists at all. 193 means no credential-bearing HTTP endpoint exists at all.
194 194
195** [webhooks] 195** [webhooks]
196- =allow_local= (false) — permit webhook and mirror targets on 196- =allow_local= (false) — permit webhook, mirror and import targets on
197 loopback, private, shared (100.64.0.0/10), link-local or multicast 197 loopback, private, shared (100.64.0.0/10), link-local or multicast
198 addresses. Leave off unless you know why you need it (SSRF). 198 addresses. Leave off unless you know why you need it (SSRF).
199 199
200** [limits] 200** [limits]
201- =clone_timeout= (3600s) — cap on =repo import= fetches. 201- =clone_timeout= (3600s) — cap on =repo import= fetches. An import
202 takes http and https URLs only, passes the same address check as a
203 mirror sync and is pinned the same way, so it needs git 2.37 too.
202- =max_blob_bytes= (100MB) — cap on raw file serving over the web. 204- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
203- =max_asset_bytes= (512MB) — cap per uploaded release asset. 205- =max_asset_bytes= (512MB) — cap per uploaded release asset.
204- =max_snippet_bytes= (1MB) — cap per snippet file. 206- =max_snippet_bytes= (1MB) — cap per snippet file.
.gitbay/wiki/Architecture/02-Components.org +1
@@ -32,6 +32,7 @@ the hidden =hook= used by git (=cmd/gitbayd/main.go=,
32| =internal/lfs= | Content-addressed LFS store and HMAC transfer tokens. | 32| =internal/lfs= | Content-addressed LFS store and HMAC transfer tokens. |
33| =internal/webhook= | Outbound webhook delivery with SSRF checks, HMAC signing, retries. | 33| =internal/webhook= | Outbound webhook delivery with SSRF checks, HMAC signing, retries. |
34| =internal/mirror= | Push and pull mirror worker. | 34| =internal/mirror= | Push and pull mirror worker. |
35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. |
35| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. | 36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. |
36| =internal/push= | APNs queue drain and provider-token signing. | 37| =internal/push= | APNs queue drain and provider-token signing. |
37| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). | 38| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -75,7 +75,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
75 75
76| Control | Status | Evidence | 76| Control | Status | Evidence |
77|---------------------------------------------+----------+------------------------------------------------------------------| 77|---------------------------------------------+----------+------------------------------------------------------------------|
78| SSRF protection on user-supplied URLs | partial | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=); =repo import --from= has no address check (#298) | 78| SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=) |
79| Webhook payload integrity | in place | HMAC-SHA256 header | 79| Webhook payload integrity | in place | HMAC-SHA256 header |
80| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) | 80| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) |
81| Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB | 81| Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −1
@@ -14,7 +14,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
17| #298 | SSRF | =repo import --from= fetches without an address check | medium | 17| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium |
18 18
19* Not filed 19* Not filed
20 20
.gitbay/wiki/Threat-Model.org +14 −11
@@ -91,18 +91,21 @@ no inbound HMAC.
91 91
92* Network-facing request forgery 92* Network-facing request forgery
93 93
94Webhook delivery, GitHub-history import =--api-base= and mirror 94Webhook delivery, GitHub-history import =--api-base=, mirror remotes
95remotes, which make the *server* open an outbound connection to a 95and =repo import --from=, which make the *server* open an outbound
96user-supplied address, pass the same SSRF guard: the scheme 96connection to a user-supplied address, pass the same SSRF guard: the
97must be http/https and, unless =webhooks.allow_local= is set, the 97scheme must be http/https and, unless =webhooks.allow_local= is set,
98resolved address must not be loopback, private, shared 98the resolved address must not be loopback, private, shared
99(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks 99(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
100at connect time, and the mirror worker resolves and checks before each 100at connect time, and mirror sync and =repo import= resolve and check
101sync and pins git to the checked addresses, so a DNS answer that 101immediately before running git and pin it to the checked addresses
102changes after validation still cannot reach private space. Redirects 102(=internal/gitpin=), so a DNS answer that changes after validation
103are never followed. =repo import --from= is the exception: its clone 103still cannot reach private space. Redirects are never followed.
104checks the scheme but not the address, and follows git's default 104=repo import= refuses =git://=, which cannot be pinned, and a host
105redirect rule (#298). 105written as a bare number or in hex/octal (=0x7f.1=, =2130706433=,
106=127.1=) rather than dotted decimal, since that form resolves
107differently across parsers. GitHub-history import (=repo
108import-issues --api-base=) is not yet pinned (#301).
106 109
107* Rendering pushed markup 110* Rendering pushed markup
108 111
.gitbay/wiki/Users.org +4
@@ -256,6 +256,10 @@ gitbay repo import-issues you/mirror --from you/repo \
256 --api-base https://codeberg.org/api/v1 # Forgejo: the site's /api/v1 256 --api-base https://codeberg.org/api/v1 # Forgejo: the site's /api/v1
257#+end_src 257#+end_src
258 258
259=repo import= fetches over http and https only, from an address that
260passes the same check as a webhook target; a =git://= URL is refused,
261so use the repository's https URL.
262
259Sourcehut has no API of that shape; =repo import= takes its git data 263Sourcehut has no API of that shape; =repo import= takes its git data
260and the todo.sr.ht tracker is not read. 264and the todo.sr.ht tracker is not read.
261 265
CHANGELOG.org +12
@@ -11,6 +11,18 @@ anything beyond "replace the binary and restart" is needed.
11 listings and shell history. A script that passed the value must pipe 11 listings and shell history. A script that passed the value must pipe
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -= 12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=
13 (#284). 13 (#284).
14- =repo import --from= takes http and https URLs only; =git://= is
15 refused, since its connection cannot be held to a checked address.
16 The host is resolved and checked like a mirror's, git connects only
17 to the checked addresses with redirects off, and the system and
18 global gitconfig are ignored. A source that redirects (a renamed
19 repository) fails; import from the URL it redirects to. Needs git
20 2.37 or later on the server (#298).
21*Upgrade note.* =repo import= and mirror sync now refuse a =git://=
22source and a =--from=/remote URL carrying a query or fragment. A
23mirror or import whose host is written numerically (=127.1=,
24=2130706433=, =0x7f.1=) rather than as a dotted address is refused
25too; rewrite it before upgrading.
14- The builds page's status badge section gives an org-mode snippet 26- The builds page's status badge section gives an org-mode snippet
15 beside the Markdown one, for a README.org (#299). 27 beside the Markdown one, for a README.org (#299).
16- API tokens on the settings page: create with a scope and optional 28- API tokens on the settings page: create with a scope and optional
e2e/import_test.go +4 −12
@@ -11,7 +11,7 @@ import (
11 11
12func TestRepoImport(t *testing.T) { 12func TestRepoImport(t *testing.T) {
13 t.Parallel() 13 t.Parallel()
14 inst := startInstance(t) 14 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
15 aliceKey := inst.newKey(t, "alice") 15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", 16 inst.admin(t, "admin", "user", "create", "alice",
17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") 17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
@@ -82,15 +82,6 @@ func TestRepoImport(t *testing.T) {
82 t.Fatalf("hooks not wired on imported repo:\n%s", pushOut) 82 t.Fatalf("hooks not wired on imported repo:\n%s", pushOut)
83 } 83 }
84 84
85 // Import over git:// too.
86 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/src", "on"); code != 0 {
87 t.Fatalf("git-daemon on: %s", errOut)
88 }
89 gitURL := fmt.Sprintf("git://127.0.0.1:%d/alice/src.git", inst.gitPort)
90 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "import", "alice/mirror2", "--from", gitURL); code != 0 {
91 t.Fatalf("git:// import: %s", errOut)
92 }
93
94 // Org-owned imports: allowed for org admins, refused for non-members. 85 // Org-owned imports: allowed for org admins, refused for non-members.
95 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "imports"); code != 0 { 86 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "imports"); code != 0 {
96 t.Fatalf("org create: %s", errOut) 87 t.Fatalf("org create: %s", errOut)
@@ -102,12 +93,13 @@ func TestRepoImport(t *testing.T) {
102 t.Fatalf("org import log: %d\n%s", code, out) 93 t.Fatalf("org import log: %d\n%s", code, out)
103 } 94 }
104 95
105 // Refusals: bad scheme, credentials in URL, existing name, foreign owner. 96 // Refusals: bad scheme, git://, credentials in URL, existing name, foreign owner.
106 cases := []struct { 97 cases := []struct {
107 args []string 98 args []string
108 want string 99 want string
109 }{ 100 }{
110 {[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "https://, http://, and git://"}, 101 {[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "http:// and https:// only"},
102 {[]string{"repo", "import", "alice/x", "--from", "git://127.0.0.1:1/alice/src.git"}, "use the repository's https:// URL"},
111 {[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"}, 103 {[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"},
112 {[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"}, 104 {[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"},
113 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "not you and not an organization"}, 105 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "not you and not an organization"},
internal/control/import.go +31 −20
@@ -10,6 +10,7 @@ import (
10 "strings" 10 "strings"
11 "time" 11 "time"
12 12
13 "gitbay.org/gitbay/internal/gitpin"
13 "gitbay.org/gitbay/internal/gitutil" 14 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy" 15 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/protocol" 16 "gitbay.org/gitbay/internal/protocol"
@@ -38,6 +39,9 @@ case "$1" in
38esac 39esac
39` 40`
40 41
42// importLookup resolves an import's host; tests replace it.
43var importLookup gitpin.Lookup = gitpin.LookupIP
44
41func runRepoImport(c *Ctx, args []string) int { 45func runRepoImport(c *Ctx, args []string) int {
42 f, err := c.parseArgs(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1, 46 f, err := c.parseArgs(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1,
43 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"}) 47 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"})
@@ -77,22 +81,39 @@ func runRepoImport(c *Ctx, args []string) int {
77 } 81 }
78 } 82 }
79 83
80 // Scheme allowlist. file:// (and anything else local) would read the 84 // http and https only. git:// has no equivalent of curl's resolve
81 // server's filesystem; ssh:// would use the server's own keys. 85 // list, so its connection cannot be held to a checked address;
82 switch { 86 // file:// would read the server's filesystem, and ssh:// would use
83 case strings.HasPrefix(from, "https://"), strings.HasPrefix(from, "http://"), strings.HasPrefix(from, "git://"): 87 // the server's own keys.
84 default: 88 if !strings.HasPrefix(from, "https://") && !strings.HasPrefix(from, "http://") {
85 return c.fail(protocol.ExitUsage, "import supports https://, http://, and git:// URLs only") 89 return c.fail(protocol.ExitUsage, "import fetches over http:// and https:// only; use the repository's https:// URL")
86 } 90 }
87 if strings.ContainsAny(from, "@") { 91 if strings.ContainsAny(from, "@") {
88 // Credentials belong on stdin, not in the URL where they would 92 // Credentials belong on stdin, not in the URL where they would
89 // land in process listings and logs. 93 // land in process listings and logs.
90 return c.fail(protocol.ExitUsage, "do not embed credentials in the URL; use --token-stdin") 94 return c.fail(protocol.ExitUsage, "do not embed credentials in the URL; use --token-stdin")
91 } 95 }
96 if strings.ContainsAny(from, "?#") {
97 // The URL is logged and recorded; a query could carry a token.
98 return c.fail(protocol.ExitUsage, "use the plain clone URL, without a query or fragment; credentials go on stdin with --token-stdin, never in the URL")
99 }
100
101 // Resolve and check the host now and hold git to those addresses,
102 // as mirror sync does (#298).
103 timeout := time.Duration(c.Cfg.Limits.CloneTimeoutSec) * time.Second
104 ctx, cancel := context.WithTimeout(context.Background(), timeout)
105 defer cancel()
106 if err := gitpin.CheckGit(ctx); err != nil {
107 return c.fail(protocol.ExitFailure, "import unavailable: %v", err)
108 }
109 remote, err := gitpin.Resolve(ctx, importLookup, from, c.Cfg.Webhooks.AllowLocal)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
92 113
93 // The token is read from stdin and handed to git via GIT_ASKPASS and 114 // The token is read from stdin and handed to git via GIT_ASKPASS and
94 // the environment — never argv, never the database, never a log line. 115 // the environment — never argv, never the database, never a log line.
95 var env []string 116 env := gitpin.Env(c.Cfg.Server.Root)
96 if tokenStdin { 117 if tokenStdin {
97 token, err := bufio.NewReader(io.LimitReader(c.Stdin, 4096)).ReadString('\n') 118 token, err := bufio.NewReader(io.LimitReader(c.Stdin, 4096)).ReadString('\n')
98 if err != nil && err != io.EOF { 119 if err != nil && err != io.EOF {
@@ -106,13 +127,7 @@ func runRepoImport(c *Ctx, args []string) int {
106 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { 127 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
107 return c.fail(protocol.ExitFailure, "%v", err) 128 return c.fail(protocol.ExitFailure, "%v", err)
108 } 129 }
109 env = []string{ 130 env = append(env, "GIT_ASKPASS="+askpass, "GITBAY_IMPORT_TOKEN="+token)
110 "GIT_ASKPASS=" + askpass,
111 "GITBAY_IMPORT_TOKEN=" + token,
112 "GIT_TERMINAL_PROMPT=0",
113 }
114 } else {
115 env = []string{"GIT_TERMINAL_PROMPT=0"}
116 } 131 }
117 132
118 visibility := "public" 133 visibility := "public"
@@ -143,17 +158,13 @@ func runRepoImport(c *Ctx, args []string) int {
143 return c.fail(protocol.ExitFailure, "%v", err) 158 return c.fail(protocol.ExitFailure, "%v", err)
144 } 159 }
145 160
146 timeout := time.Duration(c.Cfg.Limits.CloneTimeoutSec) * time.Second
147 ctx, cancel := context.WithTimeout(context.Background(), timeout)
148 defer cancel()
149
150 fmt.Fprintf(c.Stderr, "importing %s into %s ...\n", from, path) 161 fmt.Fprintf(c.Stderr, "importing %s into %s ...\n", from, path)
151 if err := gitutil.FetchMirror(ctx, dir, from, c.Stderr, env); err != nil { 162 if err := gitutil.FetchMirror(ctx, dir, from, c.Stderr, remote.Args(), env); err != nil {
152 cleanup() 163 cleanup()
153 return c.fail(protocol.ExitFailure, "import failed: %v", err) 164 return c.fail(protocol.ExitFailure, "import failed: %v", err)
154 } 165 }
155 166
156 branch, err := gitutil.RemoteDefaultBranch(ctx, from, env) 167 branch, err := gitutil.RemoteDefaultBranch(ctx, dir, from, remote.Args(), env)
157 if err != nil { 168 if err != nil {
158 branch = "main" // remote gone quiet after the fetch; keep the default 169 branch = "main" // remote gone quiet after the fetch; keep the default
159 } 170 }
internal/control/import_test.go added +148
@@ -0,0 +1,148 @@
1package control
2
3import (
4 "bytes"
5 "context"
6 "net"
7 "net/http/cgi"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15
16 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/store"
18)
19
20func importCtx(t *testing.T, allowLocal bool) (*Ctx, *bytes.Buffer, *store.Store, string) {
21 t.Helper()
22 st, _, uid := newQueueTestRepo(t)
23 root := t.TempDir()
24 c, errOut := pruneCtx(st, root, store.User{ID: uid, Username: "alice"})
25 c.Cfg.Limits.WriteRate = -1
26 c.Cfg.Limits.CloneTimeoutSec = 60
27 c.Cfg.Webhooks.AllowLocal = allowLocal
28 c.Stdin = strings.NewReader("")
29 return c, errOut, st, root
30}
31
32// importUpstream serves a bare repository with one commit on main over
33// smart HTTP and returns its URL and that commit.
34func importUpstream(t *testing.T) (string, string) {
35 t.Helper()
36 git := gitRunner(t)
37 parent := t.TempDir()
38 bare := filepath.Join(parent, "remote.git")
39 work := filepath.Join(parent, "work")
40 git(parent, "init", "-q", "--bare", "--initial-branch=main", bare)
41 git(parent, "init", "-q", "--initial-branch=main", work)
42 git(work, "commit", "-q", "--allow-empty", "-m", "one")
43 git(work, "push", "-q", bare, "main")
44 sha := strings.TrimSpace(git(work, "rev-parse", "HEAD"))
45 execPath := strings.TrimSpace(git(parent, "--exec-path"))
46 srv := httptest.NewServer(&cgi.Handler{
47 Path: filepath.Join(execPath, "git-http-backend"),
48 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
49 })
50 t.Cleanup(srv.Close)
51 return srv.URL + "/remote.git", sha
52}
53
54// git:// cannot be held to a checked address, so import refuses it
55// before creating anything (#298).
56func TestRepoImportRefusesGitScheme(t *testing.T) {
57 c, errOut, st, _ := importCtx(t, true)
58 code := Dispatch(c, []string{"repo", "import", "alice/x", "--from", "git://example.org/x.git"})
59 if code != protocol.ExitUsage || !strings.Contains(errOut.String(), "use the repository's https:// URL") {
60 t.Fatalf("exit %d, %q", code, errOut.String())
61 }
62 if _, err := st.RepoByPath("alice/x"); err == nil {
63 t.Fatal("a refused import created a repository")
64 }
65}
66
67// A reachable source on loopback is refused on a default instance, and
68// nothing is left behind.
69func TestRepoImportRefusesALocalAddress(t *testing.T) {
70 remote, _ := importUpstream(t)
71 c, errOut, st, root := importCtx(t, false)
72 code := Dispatch(c, []string{"repo", "import", "alice/x", "--from", remote})
73 if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "private or local address") {
74 t.Fatalf("exit %d, %q", code, errOut.String())
75 }
76 if _, err := st.RepoByPath("alice/x"); err == nil {
77 t.Fatal("a refused import created a repository")
78 }
79 if _, err := os.Stat(RepoDir(root, "alice", "x")); !os.IsNotExist(err) {
80 t.Fatalf("a refused import left a directory: %v", err)
81 }
82}
83
84// A query or fragment could carry a credential into the log and the
85// event row; import refuses it before either.
86func TestRepoImportRefusesAQuery(t *testing.T) {
87 for _, from := range []string{"https://git.example/x.git?token=abc", "https://git.example/x.git#abc"} {
88 c, errOut, st, _ := importCtx(t, true)
89 code := Dispatch(c, []string{"repo", "import", "alice/x", "--from", from})
90 if code != protocol.ExitUsage || !strings.Contains(errOut.String(), "plain clone URL") || strings.Contains(errOut.String(), "abc") {
91 t.Fatalf("%s: exit %d, %q", from, code, errOut.String())
92 }
93 if _, err := st.RepoByPath("alice/x"); err == nil {
94 t.Fatalf("%s: a refused import created a repository", from)
95 }
96 }
97}
98
99// import.test does not resolve; the import works only because git was
100// pinned to the address import looked up and checked.
101func TestRepoImportConnectsToTheCheckedAddress(t *testing.T) {
102 importThroughPin(t, func(string) {})
103}
104
105// git runs with its own environment, not the daemon's: a proxy or a
106// global URL rewrite there would take it around the pin.
107func TestRepoImportIgnoresTheDaemonEnvironment(t *testing.T) {
108 importThroughPin(t, func(port string) {
109 t.Setenv("http_proxy", "http://127.0.0.1:1")
110 t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
111 global := filepath.Join(t.TempDir(), "gitconfig")
112 rewrite := "[url \"http://127.0.0.1:1/\"]\n\tinsteadOf = http://import.test:" + port + "/\n"
113 if err := os.WriteFile(global, []byte(rewrite), 0o644); err != nil {
114 t.Fatal(err)
115 }
116 t.Setenv("GIT_CONFIG_GLOBAL", global)
117 })
118}
119
120func importThroughPin(t *testing.T, setup func(port string)) {
121 t.Helper()
122 remote, sha := importUpstream(t)
123 u, _ := url.Parse(remote)
124 setup(u.Port())
125 c, errOut, _, root := importCtx(t, true)
126 var asked []string
127 prev := importLookup
128 importLookup = func(ctx context.Context, host string) ([]net.IP, error) {
129 asked = append(asked, host)
130 return []net.IP{net.ParseIP("127.0.0.1")}, nil
131 }
132 t.Cleanup(func() { importLookup = prev })
133
134 code := Dispatch(c, []string{"repo", "import", "alice/copy", "--from", "http://import.test:" + u.Port() + "/remote.git"})
135 if code != protocol.ExitOK {
136 t.Fatalf("exit %d: %s", code, errOut.String())
137 }
138 if out := c.Stdout.(*bytes.Buffer).String(); !strings.Contains(out, "default main") {
139 t.Fatalf("output %q: the default branch was not read through the pin", out)
140 }
141 got := strings.TrimSpace(gitRunner(t)(RepoDir(root, "alice", "copy"), "rev-parse", "refs/heads/main"))
142 if got != sha {
143 t.Fatalf("main = %s, want %s", got, sha)
144 }
145 if !slices.Equal(asked, []string{"import.test"}) {
146 t.Fatalf("looked up %v", asked)
147 }
148}
internal/gitpin/gitpin.go added +152
@@ -0,0 +1,152 @@
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).
4package gitpin
5
6import (
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.
20type Lookup func(ctx context.Context, host string) ([]net.IP, error)
21
22// LookupIP is the system resolver.
23func 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.
29type 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.
37func 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 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 }
54 ips, err := lookup(ctx, host)
55 if err != nil {
56 return Remote{}, fmt.Errorf("resolving %s: %w", host, err)
57 }
58 if len(ips) == 0 {
59 // An empty resolve list would leave curl to resolve the host itself.
60 return Remote{}, fmt.Errorf("%s resolves to no address", host)
61 }
62 if err := webhook.CheckAddrs(host, ips, allowLocal); err != nil {
63 return Remote{}, err
64 }
65 return Remote{URL: u, IPs: ips}, nil
66}
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
83// Args are git's leading -c options for r: curl's resolve list pins
84// the host, and any other name on the same port, to the checked
85// addresses, and with redirects off a server
86// cannot send git on to a host nobody checked. An address literal
87// needs no pin.
88func (r Remote) Args() []string {
89 args := []string{"-c", "http.followRedirects=false"}
90 host := r.URL.Hostname()
91 if net.ParseIP(host) != nil {
92 return args
93 }
94 port := r.URL.Port()
95 if port == "" {
96 port = "443"
97 if r.URL.Scheme == "http" {
98 port = "80"
99 }
100 }
101 addrs := make([]string, len(r.IPs))
102 for i, ip := range r.IPs {
103 if ip.To4() == nil {
104 addrs[i] = "[" + ip.String() + "]"
105 } else {
106 addrs[i] = ip.String()
107 }
108 }
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)
114}
115
116// Env is git's whole environment for a pinned remote. No system or
117// global gitconfig: a proxy, URL rewrite or redirect setting there
118// would take git around the pin.
119func Env(home string) []string {
120 return []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + home,
121 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
122}
123
124// VersionOK accepts the output of `git version` for git 2.37 or later,
125// the first release with http.curloptResolve. An older git ignores the
126// setting and would resolve the host itself.
127func VersionOK(out string) error {
128 fields := strings.Fields(out)
129 if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" {
130 parts := strings.Split(fields[2], ".")
131 if len(parts) >= 2 {
132 major, err1 := strconv.Atoi(parts[0])
133 minor, err2 := strconv.Atoi(parts[1])
134 if err1 == nil && err2 == nil {
135 if major > 2 || major == 2 && minor >= 37 {
136 return nil
137 }
138 return fmt.Errorf("git %s is older than 2.37 and cannot pin remote addresses", fields[2])
139 }
140 }
141 }
142 return fmt.Errorf("cannot read git version from %q", strings.TrimSpace(out))
143}
144
145// CheckGit runs the server's git and refuses one that cannot pin.
146func CheckGit(ctx context.Context) error {
147 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output()
148 if err != nil {
149 return fmt.Errorf("running git version: %v", err)
150 }
151 return VersionOK(string(out))
152}
internal/gitpin/gitpin_test.go added +110
@@ -0,0 +1,110 @@
1package gitpin
2
3import (
4 "context"
5 "net"
6 "net/url"
7 "slices"
8 "strings"
9 "testing"
10)
11
12func 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
22func 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// 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
69func TestArgs(t *testing.T) {
70 u, _ := url.Parse("https://git.example/x.git")
71 got := Remote{u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}}.Args()
72 want := []string{"-c", "http.followRedirects=false",
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]"}
75 if !slices.Equal(got, want) {
76 t.Fatalf("https: %q", got)
77 }
78 u, _ = url.Parse("http://git.example:8080/x.git")
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" {
81 t.Fatalf("http with port: %q", got)
82 }
83 // An address literal is its own resolution; there is nothing to pin.
84 u, _ = url.Parse("https://203.0.113.5/x.git")
85 if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
86 t.Fatalf("literal: %q", got)
87 }
88}
89
90func TestEnv(t *testing.T) {
91 want := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/srv/gitbay",
92 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
93 if got := Env("/srv/gitbay"); !slices.Equal(got, want) {
94 t.Fatalf("Env = %q", got)
95 }
96}
97
98func TestVersionOK(t *testing.T) {
99 for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)",
100 "git version 2.45.2.windows.1", "git version 3.0.0\n"} {
101 if err := VersionOK(s); err != nil {
102 t.Errorf("%q: %v", s, err)
103 }
104 }
105 for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} {
106 if err := VersionOK(s); err == nil {
107 t.Errorf("%q accepted", s)
108 }
109 }
110}
internal/gitutil/gitutil.go +13 −9
@@ -217,14 +217,16 @@ func ReadCommit(dir, sha string) ([]byte, error) {
217 217
218// FetchMirror pulls all branches, tags, and notes from a foreign URL into 218// FetchMirror pulls all branches, tags, and notes from a foreign URL into
219// the bare repository at dir, forcing updates. Progress streams to errW so 219// the bare repository at dir, forcing updates. Progress streams to errW so
220// an interactive caller can watch. extraEnv carries credentials via 220// an interactive caller can watch. pin is git's leading -c options
221// GIT_ASKPASS; the URL itself must never contain them. 221// (gitpin.Remote.Args); env is git's whole environment and carries
222func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error { 222// credentials via GIT_ASKPASS: the URL itself must never contain them.
223 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url, 223func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, pin, env []string) error {
224 args := append(append([]string{}, pin...), "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
224 "+refs/heads/*:refs/heads/*", 225 "+refs/heads/*:refs/heads/*",
225 "+refs/tags/*:refs/tags/*", 226 "+refs/tags/*:refs/tags/*",
226 "+refs/notes/*:refs/notes/*") 227 "+refs/notes/*:refs/notes/*")
227 cmd.Env = append(os.Environ(), extraEnv...) 228 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
229 cmd.Env = env
228 cmd.Stderr = errW 230 cmd.Stderr = errW
229 if err := cmd.Run(); err != nil { 231 if err := cmd.Run(); err != nil {
230 return fmt.Errorf("fetch from %s: %w", url, err) 232 return fmt.Errorf("fetch from %s: %w", url, err)
@@ -253,10 +255,12 @@ func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraE
253 return nil 255 return nil
254} 256}
255 257
256// RemoteDefaultBranch asks the remote which branch HEAD points at. 258// RemoteDefaultBranch asks the remote which branch HEAD points at,
257func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) { 259// running in the repository at dir. pin and env are as for FetchMirror.
258 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "ls-remote", "--symref", url, "HEAD") 260func RemoteDefaultBranch(ctx context.Context, dir, url string, pin, env []string) (string, error) {
259 cmd.Env = append(os.Environ(), extraEnv...) 261 args := append(append([]string{}, pin...), "-C", dir, "ls-remote", "--symref", url, "HEAD")
262 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
263 cmd.Env = env
260 out, err := cmd.Output() 264 out, err := cmd.Output()
261 if err != nil { 265 if err != nil {
262 return "", fmt.Errorf("ls-remote %s: %w", url, err) 266 return "", fmt.Errorf("ls-remote %s: %w", url, err)
internal/mirror/mirror.go +7 −83
@@ -10,19 +10,16 @@ import (
10 "fmt" 10 "fmt"
11 "log/slog" 11 "log/slog"
12 "net" 12 "net"
13 "net/url"
14 "os" 13 "os"
15 "os/exec" 14 "os/exec"
16 "path/filepath" 15 "path/filepath"
17 "strconv"
18 "strings"
19 "time" 16 "time"
20 17
21 "gitbay.org/gitbay/internal/config" 18 "gitbay.org/gitbay/internal/config"
22 "gitbay.org/gitbay/internal/control" 19 "gitbay.org/gitbay/internal/control"
20 "gitbay.org/gitbay/internal/gitpin"
23 "gitbay.org/gitbay/internal/store" 21 "gitbay.org/gitbay/internal/store"
24 "gitbay.org/gitbay/internal/toolpath" 22 "gitbay.org/gitbay/internal/toolpath"
25 "gitbay.org/gitbay/internal/webhook"
26) 23)
27 24
28const askpassScript = `#!/bin/sh 25const askpassScript = `#!/bin/sh
@@ -50,20 +47,12 @@ func New(st *store.Store, cfg config.Config) *Worker {
50 tick = d 47 tick = d
51 } 48 }
52 } 49 }
53 return &Worker{St: st, Cfg: cfg, Tick: tick, 50 return &Worker{St: st, Cfg: cfg, Tick: tick, Lookup: gitpin.LookupIP}
54 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
55 return net.DefaultResolver.LookupIP(ctx, "ip", host)
56 }}
57} 51}
58 52
59func (w *Worker) Run(ctx context.Context) { 53func (w *Worker) Run(ctx context.Context) {
60 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output() 54 if err := gitpin.CheckGit(ctx); err != nil {
61 if err != nil { 55 w.gitErr = fmt.Errorf("mirrors disabled: %w", err)
62 w.gitErr = fmt.Errorf("mirrors disabled: running git version: %v", err)
63 } else {
64 w.gitErr = gitVersionOK(string(out))
65 }
66 if w.gitErr != nil {
67 slog.Error("mirror: not syncing", "err", w.gitErr) 56 slog.Error("mirror: not syncing", "err", w.gitErr)
68 } 57 }
69 t := time.NewTicker(w.Tick) 58 t := time.NewTicker(w.Tick)
@@ -105,35 +94,18 @@ func (w *Worker) sync(m store.Mirror) error {
105 return err 94 return err
106 } 95 }
107 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) 96 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
108 u, err := url.Parse(m.URL)
109 if err != nil {
110 return err
111 }
112 if u.Scheme != "https" && u.Scheme != "http" {
113 return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme)
114 }
115 97
116 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 98 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
117 defer cancel() 99 defer cancel()
118 // The URL was checked when saved, but DNS can answer differently 100 // The URL was checked when saved, but DNS can answer differently
119 // now. Check what it resolves to at sync time, then let git connect 101 // now. Check what it resolves to at sync time, then let git connect
120 // to exactly those addresses. 102 // to exactly those addresses.
121 ips, err := w.Lookup(ctx, u.Hostname()) 103 remote, err := gitpin.Resolve(ctx, w.Lookup, m.URL, w.Cfg.Webhooks.AllowLocal)
122 if err != nil { 104 if err != nil {
123 return fmt.Errorf("resolving %s: %w", u.Hostname(), err)
124 }
125 if len(ips) == 0 {
126 // An empty resolve list would leave curl to resolve the host itself.
127 return fmt.Errorf("%s resolves to no address", u.Hostname())
128 }
129 if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil {
130 return err 105 return err
131 } 106 }
132 107
133 // No system or global gitconfig: a proxy, URL rewrite or redirect 108 env := gitpin.Env(w.Cfg.Server.Root)
134 // setting there would take git around the pin.
135 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root,
136 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
137 if m.Token != "" { 109 if m.Token != "" {
138 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh") 110 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
139 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { 111 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
@@ -149,7 +121,7 @@ func (w *Worker) sync(m store.Mirror) error {
149 "GITBAY_MIRROR_TOKEN="+m.Token) 121 "GITBAY_MIRROR_TOKEN="+m.Token)
150 } 122 }
151 123
152 args := append(pinArgs(u, ips), "-C", dir) 124 args := append(remote.Args(), "-C", dir)
153 if m.Direction == "push" { 125 if m.Direction == "push" {
154 // Branches and tags only: internal refs (merge-requests) stay home. 126 // Branches and tags only: internal refs (merge-requests) stay home.
155 args = append(args, "push", "--prune", m.URL, 127 args = append(args, "push", "--prune", m.URL,
@@ -165,51 +137,3 @@ func (w *Worker) sync(m store.Mirror) error {
165 } 137 }
166 return nil 138 return nil
167} 139}
168
169// pinArgs keeps git on the addresses just checked: curl's resolve list
170// pins the host, and with redirects off a server cannot send git on to
171// a host nobody checked. An address literal needs no pin.
172func pinArgs(u *url.URL, ips []net.IP) []string {
173 args := []string{"-c", "http.followRedirects=false"}
174 host := u.Hostname()
175 if net.ParseIP(host) != nil {
176 return args
177 }
178 port := u.Port()
179 if port == "" {
180 port = "443"
181 if u.Scheme == "http" {
182 port = "80"
183 }
184 }
185 addrs := make([]string, len(ips))
186 for i, ip := range ips {
187 if ip.To4() == nil {
188 addrs[i] = "[" + ip.String() + "]"
189 } else {
190 addrs[i] = ip.String()
191 }
192 }
193 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
194}
195
196// gitVersionOK accepts the output of `git version` for git 2.37 or
197// later, the first release with http.curloptResolve. An older git
198// ignores the setting and would resolve the host itself.
199func gitVersionOK(out string) error {
200 fields := strings.Fields(out)
201 if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" {
202 parts := strings.Split(fields[2], ".")
203 if len(parts) >= 2 {
204 major, err1 := strconv.Atoi(parts[0])
205 minor, err2 := strconv.Atoi(parts[1])
206 if err1 == nil && err2 == nil {
207 if major > 2 || major == 2 && minor >= 37 {
208 return nil
209 }
210 return fmt.Errorf("mirrors disabled: git %s is older than 2.37 and cannot pin mirror addresses", fields[2])
211 }
212 }
213 }
214 return fmt.Errorf("mirrors disabled: cannot read git version from %q", strings.TrimSpace(out))
215}
internal/mirror/mirror_test.go +2 −34
@@ -15,6 +15,7 @@ import (
15 15
16 "gitbay.org/gitbay/internal/config" 16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/control" 17 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/gitpin"
18 "gitbay.org/gitbay/internal/store" 19 "gitbay.org/gitbay/internal/store"
19) 20)
20 21
@@ -148,7 +149,7 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) {
148 t.Fatal("looked up a host with an old git") 149 t.Fatal("looked up a host with an old git")
149 return nil, nil 150 return nil, nil
150 }} 151 }}
151 w.gitErr = gitVersionOK("git version 2.36.1") 152 w.gitErr = gitpin.VersionOK("git version 2.36.1")
152 w.sweep() 153 w.sweep()
153 ms, err := st.ListMirrors(m.RepoID) 154 ms, err := st.ListMirrors(m.RepoID)
154 if err != nil || len(ms) != 1 { 155 if err != nil || len(ms) != 1 {
@@ -159,20 +160,6 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) {
159 } 160 }
160} 161}
161 162
162func TestGitVersionOK(t *testing.T) {
163 for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)",
164 "git version 2.45.2.windows.1", "git version 3.0.0\n"} {
165 if err := gitVersionOK(s); err != nil {
166 t.Errorf("%q: %v", s, err)
167 }
168 }
169 for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} {
170 if err := gitVersionOK(s); err == nil {
171 t.Errorf("%q accepted", s)
172 }
173 }
174}
175
176// The URL passed the check when it was saved; the answer at sync time 163// The URL passed the check when it was saved; the answer at sync time
177// is what counts. 164// is what counts.
178func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) { 165func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
@@ -238,22 +225,3 @@ func TestSyncRefusesANonHTTPScheme(t *testing.T) {
238 t.Fatalf("sync = %v, want a refusal", err) 225 t.Fatalf("sync = %v, want a refusal", err)
239 } 226 }
240} 227}
241
242func TestPinArgs(t *testing.T) {
243 u, _ := url.Parse("https://git.example/x.git")
244 got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")})
245 want := []string{"-c", "http.followRedirects=false",
246 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
247 if !slices.Equal(got, want) {
248 t.Fatalf("https: %q", got)
249 }
250 u, _ = url.Parse("http://git.example:8080/x.git")
251 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
252 t.Fatalf("http with port: %q", got)
253 }
254 // An address literal is its own resolution; there is nothing to pin.
255 u, _ = url.Parse("https://203.0.113.5/x.git")
256 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
257 t.Fatalf("literal: %q", got)
258 }
259}