import: http(s) only, checked and pinned like mirrors !512
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 | ||
| 94 | Webhook delivery, GitHub-history import =--api-base= and mirror | 94 | Webhook delivery, GitHub-history import =--api-base=, mirror remotes |
| 95 | remotes, which make the *server* open an outbound connection to a | 95 | and =repo import --from=, which make the *server* open an outbound |
| 96 | user-supplied address, pass the same SSRF guard: the scheme | 96 | connection to a user-supplied address, pass the same SSRF guard: the |
| 97 | must be http/https and, unless =webhooks.allow_local= is set, the | 97 | scheme must be http/https and, unless =webhooks.allow_local= is set, |
| 98 | resolved address must not be loopback, private, shared | 98 | the 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 |
| 100 | at connect time, and the mirror worker resolves and checks before each | 100 | at connect time, and mirror sync and =repo import= resolve and check |
| 101 | sync and pins git to the checked addresses, so a DNS answer that | 101 | immediately before running git and pin it to the checked addresses |
| 102 | changes after validation still cannot reach private space. Redirects | 102 | (=internal/gitpin=), so a DNS answer that changes after validation |
| 103 | are never followed. =repo import --from= is the exception: its clone | 103 | still cannot reach private space. Redirects are never followed. |
| 104 | checks the scheme but not the address, and follows git's default | 104 | =repo import= refuses =git://=, which cannot be pinned, and a host |
| 105 | redirect rule (#298). | 105 | written as a bare number or in hex/octal (=0x7f.1=, =2130706433=, |
| 106 | =127.1=) rather than dotted decimal, since that form resolves | ||
| 107 | differently across parsers. GitHub-history import (=repo | ||
| 108 | import-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 | ||
| 260 | passes the same check as a webhook target; a =git://= URL is refused, | ||
| 261 | so use the repository's https URL. | ||
| 262 | |||
| 259 | Sourcehut has no API of that shape; =repo import= takes its git data | 263 | Sourcehut has no API of that shape; =repo import= takes its git data |
| 260 | and the todo.sr.ht tracker is not read. | 264 | and 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://= | ||
| 22 | source and a =--from=/remote URL carrying a query or fragment. A | ||
| 23 | mirror or import whose host is written numerically (=127.1=, | ||
| 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused | ||
| 25 | too; 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 | ||
| 12 | func TestRepoImport(t *testing.T) { | 12 | func 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 | |||
| 38 | esac | 39 | esac |
| 39 | ` | 40 | ` |
| 40 | 41 | ||
| 42 | // importLookup resolves an import's host; tests replace it. | ||
| 43 | var importLookup gitpin.Lookup = gitpin.LookupIP | ||
| 44 | |||
| 41 | func runRepoImport(c *Ctx, args []string) int { | 45 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 20 | func 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. | ||
| 34 | func 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). | ||
| 56 | func 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. | ||
| 69 | func 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. | ||
| 86 | func 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. | ||
| 101 | func 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. | ||
| 107 | func 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 | |||
| 120 | func 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). | ||
| 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 | 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. | ||
| 70 | func 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. | ||
| 88 | func (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. | ||
| 119 | func 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. | ||
| 127 | func 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. | ||
| 146 | func 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 @@ | |||
| 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 | // Numeric hosts other than a dotted quad are refused before any lookup: | ||
| 50 | // curl reads them as addresses the check never saw. | ||
| 51 | func 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 | |||
| 69 | func 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 | |||
| 90 | func 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 | |||
| 98 | func 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 |
| 222 | func 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, | 223 | func 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, |
| 257 | func 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") | 260 | func 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 | ||
| 28 | const askpassScript = `#!/bin/sh | 25 | const 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 | ||
| 59 | func (w *Worker) Run(ctx context.Context) { | 53 | func (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. | ||
| 172 | func 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. | ||
| 199 | func 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 | ||
| 162 | func 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. |
| 178 | func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) { | 165 | func 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 | |||
| 242 | func 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 | } | ||