mirror: connect only to the address checked at sync time !493

merged merged by cmc on 2026-09-28 21:50 UTC · krz/gitbay:mirror-pin-address into main

12 files changed, +464 −33

Layout: unified · split

.gitbay/wiki/API.org +3 −3
@@ -197,6 +197,6 @@ A 2xx within 10 seconds is success. Anything else retries with
197exponential backoff (30s base, doubling) and dead-letters after five 197exponential backoff (30s base, doubling) and dead-letters after five
198attempts; =webhook deliveries= shows the trail and =redeliver= revives a 198attempts; =webhook deliveries= shows the trail and =redeliver= revives a
199dead letter. Redirects are never followed, and targets resolving to 199dead letter. Redirects are never followed, and targets resolving to
200loopback/private/link-local addresses are refused both at registration 200loopback, private, shared (100.64.0.0/10), link-local or multicast
201and again at connect time, unless the instance sets 201addresses are refused both at registration and again at connect time,
202=[webhooks] allow_local=. 202unless the instance sets =[webhooks] allow_local=.
.gitbay/wiki/Admin.org +9 −2
@@ -174,7 +174,8 @@ push=.
174 means no credential-bearing HTTP endpoint exists at all. 174 means no credential-bearing HTTP endpoint exists at all.
175 175
176** [webhooks] 176** [webhooks]
177- =allow_local= (false) — permit webhook targets on loopback/private 177- =allow_local= (false) — permit webhook and mirror targets on
178 loopback, private, shared (100.64.0.0/10), link-local or multicast
178 addresses. Leave off unless you know why you need it (SSRF). 179 addresses. Leave off unless you know why you need it (SSRF).
179 180
180** [limits] 181** [limits]
@@ -198,7 +199,13 @@ push=.
198** [mirrors] 199** [mirrors]
199- =pull_interval_minutes= (15) — how often pull mirrors fetch their 200- =pull_interval_minutes= (15) — how often pull mirrors fetch their
200 upstream. Push mirrors sync shortly after each local ref update. 201 upstream. Push mirrors sync shortly after each local ref update.
201 Mirror URLs pass the same SSRF rules as webhook targets. 202 Mirror URLs pass the same SSRF rules as webhook targets, when saved
203 and again before every sync; git then connects only to the addresses
204 that were checked (=http.curloptResolve=) and does not follow
205 redirects, so a mirror of a renamed repository fails until its URL
206 is updated. Needs git 2.37 or later on the server; with an older git
207 the worker logs an error at start and syncs no mirror, recording the
208 reason on each. Sync ignores the system and global gitconfig.
202 209
203** [go_import] 210** [go_import]
204Vanity Go module paths, one per line: ="host/module" = "owner/repo"=. 211Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
.gitbay/wiki/Architecture/03-Deployment.org +2 −2
@@ -59,8 +59,8 @@ a database check.
59| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) | 59| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
60| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | 60| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
61| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | 61| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
62| Webhook URLs | recorded events | yes when https; certificate verified | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) | 62| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
63| Mirror URLs | mirror schedule | per URL | the same address check at save time (=internal/control/mirrorcmd.go=); git makes the connection, so there is no connect-time re-check | 63| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
64| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | 64| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
65 65
66* Host firewall 66* Host firewall
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
@@ -75,7 +75,7 @@ Who may do what:
75| Integration | Trigger | Security properties | 75| Integration | Trigger | Security properties |
76|-------------+----------------------+--------------------------------------------------------------------------------| 76|-------------+----------------------+--------------------------------------------------------------------------------|
77| Webhooks | recorded events | SSRF checks at save and connect time, no redirects, HMAC-SHA256 signature, 5 attempts with exponential backoff, response body capped at 4 KiB (=internal/webhook/webhook.go=) | 77| Webhooks | recorded events | SSRF checks at save and connect time, no redirects, HMAC-SHA256 signature, 5 attempts with exponential backoff, response body capped at 4 KiB (=internal/webhook/webhook.go=) |
78| Mirrors | schedule | address check at save; token via =GIT_ASKPASS= script (0700); heads and tags only; 10-minute timeout (=internal/mirror/mirror.go=) | 78| Mirrors | schedule | address check at save and before each sync, git pinned to the checked addresses, no redirects; token via =GIT_ASKPASS= script (0700); heads and tags only; 10-minute timeout (=internal/mirror/mirror.go=) |
79| Dependency checks | schedule, opt-in | fixed registry hosts; package names restricted (=internal/deps/registry.go=) | 79| Dependency checks | schedule, opt-in | fixed registry hosts; package names restricted (=internal/deps/registry.go=) |
80 80
81* The project's own supply chain 81* The project's own supply chain
.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 only (#279) | 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) |
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
@@ -19,7 +19,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
19| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | 19| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high |
20| #274 | Backups | The local backup archive is not encrypted | medium | 20| #274 | Backups | The local backup archive is not encrypted | medium |
21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | 21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
22| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium | 22| #298 | SSRF | =repo import --from= fetches without an address check | medium |
23| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | 23| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
24| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 24| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
25 25
.gitbay/wiki/Threat-Model.org +11 −7
@@ -84,14 +84,18 @@ no inbound HMAC.
84 84
85* Network-facing request forgery 85* Network-facing request forgery
86 86
87Anything that makes the *server* open an outbound connection to a 87Webhook delivery, GitHub-history import =--api-base= and mirror
88user-supplied address — webhook delivery, GitHub-history import 88remotes, which make the *server* open an outbound connection to a
89=--api-base=, mirror remotes — passes the same SSRF guard: the scheme 89user-supplied address, pass the same SSRF guard: the scheme
90must be http/https and, unless =webhooks.allow_local= is set, the 90must be http/https and, unless =webhooks.allow_local= is set, the
91resolved address must not be loopback, private, or link-local. The 91resolved address must not be loopback, private, shared
92webhook dialer re-checks at connect time so a DNS answer that changes 92(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
93after validation still cannot reach private space. Redirects are never 93at connect time, and the mirror worker resolves and checks before each
94followed. 94sync and pins git to the checked addresses, so a DNS answer that
95changes after validation still cannot reach private space. Redirects
96are never followed. =repo import --from= is the exception: its clone
97checks the scheme but not the address, and follows git's default
98redirect rule (#298).
95 99
96* Rendering pushed markup 100* Rendering pushed markup
97 101
CHANGELOG.org +9
@@ -39,6 +39,15 @@ must add =--scope full=. Existing tokens keep their scope.
39 not offer STARTTLS gets no mail unless =mail.require_tls = false= 39 not offer STARTTLS gets no mail unless =mail.require_tls = false=
40 restores the old behaviour. =mail.tls = "implicit"= speaks TLS from 40 restores the old behaviour. =mail.tls = "implicit"= speaks TLS from
41 the first byte, for relays on port 465 (#280). 41 the first byte, for relays on port 465 (#280).
42- Mirror sync resolves the host, checks the addresses and connects git
43 only to them, with redirects off; a URL that now resolves to private
44 space, or is not http or https, fails the sync with the reason on
45 =repo mirror list=. A mirror of a renamed repository that redirects
46 fails until its URL is updated. Sync ignores the system and global
47 gitconfig. Needs git 2.37 or later: with an older git no mirror
48 syncs, and each records why (#279).
49- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
50 address are refused, as private addresses are (#279).
42 51
43* v1.36.0 — 2026-09-23 52* v1.36.0 — 2026-09-23
44 53
internal/mirror/mirror.go +108 −9
@@ -9,15 +9,20 @@ import (
9 "context" 9 "context"
10 "fmt" 10 "fmt"
11 "log/slog" 11 "log/slog"
12 "net"
13 "net/url"
12 "os" 14 "os"
13 "os/exec" 15 "os/exec"
14 "path/filepath" 16 "path/filepath"
17 "strconv"
18 "strings"
15 "time" 19 "time"
16 20
17 "gitbay.org/gitbay/internal/config" 21 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control" 22 "gitbay.org/gitbay/internal/control"
19 "gitbay.org/gitbay/internal/store" 23 "gitbay.org/gitbay/internal/store"
20 "gitbay.org/gitbay/internal/toolpath" 24 "gitbay.org/gitbay/internal/toolpath"
25 "gitbay.org/gitbay/internal/webhook"
21) 26)
22 27
23const askpassScript = `#!/bin/sh 28const askpassScript = `#!/bin/sh
@@ -31,6 +36,11 @@ type Worker struct {
31 St *store.Store 36 St *store.Store
32 Cfg config.Config 37 Cfg config.Config
33 Tick time.Duration 38 Tick time.Duration
39 // Lookup resolves a mirror's host immediately before each sync.
40 Lookup func(ctx context.Context, host string) ([]net.IP, error)
41 // gitErr is set when the server's git cannot pin addresses; no
42 // mirror syncs while it is.
43 gitErr error
34} 44}
35 45
36func New(st *store.Store, cfg config.Config) *Worker { 46func New(st *store.Store, cfg config.Config) *Worker {
@@ -40,10 +50,22 @@ func New(st *store.Store, cfg config.Config) *Worker {
40 tick = d 50 tick = d
41 } 51 }
42 } 52 }
43 return &Worker{St: st, Cfg: cfg, Tick: tick} 53 return &Worker{St: st, Cfg: cfg, Tick: tick,
54 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
55 return net.DefaultResolver.LookupIP(ctx, "ip", host)
56 }}
44} 57}
45 58
46func (w *Worker) Run(ctx context.Context) { 59func (w *Worker) Run(ctx context.Context) {
60 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output()
61 if err != nil {
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)
68 }
47 t := time.NewTicker(w.Tick) 69 t := time.NewTicker(w.Tick)
48 defer t.Stop() 70 defer t.Stop()
49 for { 71 for {
@@ -64,6 +86,10 @@ func (w *Worker) sweep() {
64 return 86 return
65 } 87 }
66 for _, m := range due { 88 for _, m := range due {
89 if w.gitErr != nil {
90 w.St.SetMirrorResult(m.ID, w.gitErr.Error())
91 continue
92 }
67 if err := w.sync(m); err != nil { 93 if err := w.sync(m); err != nil {
68 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err) 94 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err)
69 w.St.SetMirrorResult(m.ID, err.Error()) 95 w.St.SetMirrorResult(m.ID, err.Error())
@@ -79,8 +105,35 @@ func (w *Worker) sync(m store.Mirror) error {
79 return err 105 return err
80 } 106 }
81 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) 107 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 }
82 115
83 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root} 116 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
117 defer cancel()
118 // 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
120 // to exactly those addresses.
121 ips, err := w.Lookup(ctx, u.Hostname())
122 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
131 }
132
133 // No system or global gitconfig: a proxy, URL rewrite or redirect
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"}
84 if m.Token != "" { 137 if m.Token != "" {
85 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh") 138 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
86 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { 139 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
@@ -96,16 +149,14 @@ func (w *Worker) sync(m store.Mirror) error {
96 "GITBAY_MIRROR_TOKEN="+m.Token) 149 "GITBAY_MIRROR_TOKEN="+m.Token)
97 } 150 }
98 151
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 152 args := append(pinArgs(u, ips), "-C", dir)
100 defer cancel()
101 var args []string
102 if m.Direction == "push" { 153 if m.Direction == "push" {
103 // Branches and tags only: internal refs (merge-requests) stay home. 154 // Branches and tags only: internal refs (merge-requests) stay home.
104 args = []string{"-C", dir, "push", "--prune", m.URL, 155 args = append(args, "push", "--prune", m.URL,
105 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} 156 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
106 } else { 157 } else {
107 args = []string{"-C", dir, "fetch", "--prune", m.URL, 158 args = append(args, "fetch", "--prune", m.URL,
108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} 159 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
109 } 160 }
110 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...) 161 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
111 cmd.Env = env 162 cmd.Env = env
@@ -114,3 +165,51 @@ func (w *Worker) sync(m store.Mirror) error {
114 } 165 }
115 return nil 166 return nil
116} 167}
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 added +259
@@ -0,0 +1,259 @@
1package mirror
2
3import (
4 "context"
5 "net"
6 "net/http/cgi"
7 "net/http/httptest"
8 "net/url"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15
16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func git(t *testing.T, dir string, args ...string) string {
22 t.Helper()
23 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
24 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+t.TempDir(),
25 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
26 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
27 out, err := cmd.CombinedOutput()
28 if err != nil {
29 t.Fatalf("git %v: %v\n%s", args, err, out)
30 }
31 return strings.TrimSpace(string(out))
32}
33
34// upstream serves a bare repository with one commit on main over smart
35// HTTP and returns its URL and that commit.
36func upstream(t *testing.T) (string, string) {
37 t.Helper()
38 parent := t.TempDir()
39 bare := filepath.Join(parent, "remote.git")
40 work := filepath.Join(parent, "work")
41 git(t, parent, "init", "-q", "--bare", "--initial-branch=main", bare)
42 git(t, parent, "init", "-q", "--initial-branch=main", work)
43 git(t, work, "commit", "-q", "--allow-empty", "-m", "one")
44 git(t, work, "push", "-q", bare, "main")
45 sha := git(t, work, "rev-parse", "HEAD")
46 execPath := git(t, parent, "--exec-path")
47 srv := httptest.NewServer(&cgi.Handler{
48 Path: filepath.Join(execPath, "git-http-backend"),
49 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
50 })
51 t.Cleanup(srv.Close)
52 return srv.URL + "/remote.git", sha
53}
54
55// local returns a store with alice/app, its bare repository under root,
56// and the pull mirror row for url.
57func local(t *testing.T, root, mirrorURL string) (*store.Store, store.Mirror, string) {
58 t.Helper()
59 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
60 if err != nil {
61 t.Fatal(err)
62 }
63 t.Cleanup(func() { st.Close() })
64 if err := st.MigrateUp(); err != nil {
65 t.Fatal(err)
66 }
67 uid, err := st.CreateUser("alice", false)
68 if err != nil {
69 t.Fatal(err)
70 }
71 repoID, err := st.CreateRepo("user", uid, "app", "public")
72 if err != nil {
73 t.Fatal(err)
74 }
75 dir := control.RepoDir(root, "alice", "app")
76 os.MkdirAll(filepath.Dir(dir), 0o755)
77 git(t, root, "init", "-q", "--bare", dir)
78 if _, err := st.AddMirror(repoID, "pull", mirrorURL, "", ""); err != nil {
79 t.Fatal(err)
80 }
81 due, err := st.DueMirrors(900)
82 if err != nil || len(due) != 1 {
83 t.Fatalf("due mirrors: %v %v", due, err)
84 }
85 return st, due[0], dir
86}
87
88// mirror.test does not resolve; the fetch works only because git was
89// pinned to the address the worker looked up and checked.
90func TestSyncConnectsToTheCheckedAddress(t *testing.T) {
91 remote, sha := upstream(t)
92 u, _ := url.Parse(remote)
93 root := t.TempDir()
94 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
95 var cfg config.Config
96 cfg.Server.Root = root
97 cfg.Webhooks.AllowLocal = true
98 var asked []string
99 w := &Worker{St: st, Cfg: cfg, Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
100 asked = append(asked, host)
101 return []net.IP{net.ParseIP("127.0.0.1")}, nil
102 }}
103 if err := w.sync(m); err != nil {
104 t.Fatal(err)
105 }
106 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
107 t.Fatalf("main = %s, want %s", got, sha)
108 }
109 if !slices.Equal(asked, []string{"mirror.test"}) {
110 t.Fatalf("looked up %v", asked)
111 }
112}
113
114// The server account's own gitconfig cannot route git around the pin:
115// a proxy and a URL rewrite in HOME's config are both ignored.
116func TestSyncIgnoresGlobalGitConfig(t *testing.T) {
117 remote, sha := upstream(t)
118 u, _ := url.Parse(remote)
119 root := t.TempDir()
120 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
121 conf := "[http]\n\tproxy = http://127.0.0.1:9\n[url \"http://elsewhere.test/\"]\n\tinsteadOf = http://mirror.test:" + u.Port() + "/\n"
122 if err := os.WriteFile(filepath.Join(root, ".gitconfig"), []byte(conf), 0o644); err != nil {
123 t.Fatal(err)
124 }
125 var cfg config.Config
126 cfg.Server.Root = root
127 cfg.Webhooks.AllowLocal = true
128 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
129 return []net.IP{net.ParseIP("127.0.0.1")}, nil
130 }}
131 if err := w.sync(m); err != nil {
132 t.Fatal(err)
133 }
134 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
135 t.Fatalf("main = %s, want %s", got, sha)
136 }
137}
138
139// A git too old for http.curloptResolve would ignore the pin; the
140// sweep refuses to sync and says why on every due mirror.
141func TestSweepRefusesWithAnOldGit(t *testing.T) {
142 root := t.TempDir()
143 st, m, _ := local(t, root, "https://mirror.test/x.git")
144 var cfg config.Config
145 cfg.Server.Root = root
146 cfg.Mirrors.PullIntervalMinutes = 15
147 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
148 t.Fatal("looked up a host with an old git")
149 return nil, nil
150 }}
151 w.gitErr = gitVersionOK("git version 2.36.1")
152 w.sweep()
153 ms, err := st.ListMirrors(m.RepoID)
154 if err != nil || len(ms) != 1 {
155 t.Fatalf("mirrors: %v %v", ms, err)
156 }
157 if !strings.Contains(ms[0].LastError, "2.37") {
158 t.Fatalf("last error = %q", ms[0].LastError)
159 }
160}
161
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
177// is what counts.
178func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
179 root := t.TempDir()
180 st, m, _ := local(t, root, "https://mirror.test/x.git")
181 var cfg config.Config
182 cfg.Server.Root = root
183 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
184 return []net.IP{net.ParseIP("10.0.0.7")}, nil
185 }}
186 err := w.sync(m)
187 if err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
188 t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err)
189 }
190}
191
192// A refusal is a sync failure like any other: the sweep records it on
193// the mirror, where repo mirror list shows it.
194func TestSweepRecordsTheRefusal(t *testing.T) {
195 root := t.TempDir()
196 st, m, _ := local(t, root, "https://mirror.test/x.git")
197 var cfg config.Config
198 cfg.Server.Root = root
199 cfg.Mirrors.PullIntervalMinutes = 15
200 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
201 return []net.IP{net.ParseIP("100.64.0.9")}, nil
202 }}
203 w.sweep()
204 ms, err := st.ListMirrors(m.RepoID)
205 if err != nil || len(ms) != 1 {
206 t.Fatalf("mirrors: %v %v", ms, err)
207 }
208 if !strings.Contains(ms[0].LastError, "100.64.0.9") {
209 t.Fatalf("last error = %q", ms[0].LastError)
210 }
211}
212
213func TestSyncRefusesAnEmptyAnswer(t *testing.T) {
214 root := t.TempDir()
215 st, m, _ := local(t, root, "https://mirror.test/x.git")
216 var cfg config.Config
217 cfg.Server.Root = root
218 cfg.Webhooks.AllowLocal = true
219 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
220 return nil, nil
221 }}
222 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "no address") {
223 t.Fatalf("sync = %v, want a refusal", err)
224 }
225}
226
227func TestSyncRefusesANonHTTPScheme(t *testing.T) {
228 root := t.TempDir()
229 st, m, _ := local(t, root, "ssh://mirror.test/x.git")
230 var cfg config.Config
231 cfg.Server.Root = root
232 cfg.Webhooks.AllowLocal = true
233 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
234 t.Fatal("looked up a host for an ssh URL")
235 return nil, nil
236 }}
237 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "not http or https") {
238 t.Fatalf("sync = %v, want a refusal", err)
239 }
240}
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}
internal/webhook/webhook.go +26 −7
@@ -20,19 +20,20 @@ import (
20 "gitbay.org/gitbay/internal/store" 20 "gitbay.org/gitbay/internal/store"
21) 21)
22 22
23// ValidateURL rejects URLs a webhook must not target: non-HTTP schemes and, 23// ValidateURL rejects URLs the server must not connect to (SSRF): non-HTTP
24// unless allowLocal, anything resolving to loopback, private, or link-local 24// schemes and, unless allowLocal, anything resolving to a loopback,
25// addresses (SSRF). 25// private, shared (100.64.0.0/10), link-local, multicast or unspecified
26// address. Webhooks, mirrors and issue import use it.
26func ValidateURL(raw string, allowLocal bool) error { 27func ValidateURL(raw string, allowLocal bool) error {
27 u, err := url.Parse(raw) 28 u, err := url.Parse(raw)
28 if err != nil { 29 if err != nil {
29 return fmt.Errorf("invalid URL: %w", err) 30 return fmt.Errorf("invalid URL: %w", err)
30 } 31 }
31 if u.Scheme != "http" && u.Scheme != "https" { 32 if u.Scheme != "http" && u.Scheme != "https" {
32 return fmt.Errorf("webhook URLs must be http or https") 33 return fmt.Errorf("URLs must be http or https")
33 } 34 }
34 if u.Hostname() == "" { 35 if u.Hostname() == "" {
35 return fmt.Errorf("webhook URL has no host") 36 return fmt.Errorf("URL has no host")
36 } 37 }
37 if allowLocal { 38 if allowLocal {
38 return nil 39 return nil
@@ -41,17 +42,35 @@ func ValidateURL(raw string, allowLocal bool) error {
41 if err != nil { 42 if err != nil {
42 return fmt.Errorf("cannot resolve %s: %w", u.Hostname(), err) 43 return fmt.Errorf("cannot resolve %s: %w", u.Hostname(), err)
43 } 44 }
45 if err := CheckAddrs(u.Hostname(), ips, allowLocal); err != nil {
46 return fmt.Errorf("target %s resolves to a private or local address; refusing (SSRF)", u.Hostname())
47 }
48 return nil
49}
50
51// CheckAddrs refuses host when any of its resolved addresses is
52// loopback, private, shared (100.64.0.0/10), link-local, multicast or
53// unspecified, unless allowLocal. A caller resolves immediately before
54// connecting and connects only to the addresses it checked.
55func CheckAddrs(host string, ips []net.IP, allowLocal bool) error {
56 if allowLocal {
57 return nil
58 }
44 for _, ip := range ips { 59 for _, ip := range ips {
45 if isForbidden(ip) { 60 if isForbidden(ip) {
46 return fmt.Errorf("webhook target %s resolves to a private or local address; refusing (SSRF)", u.Hostname()) 61 return fmt.Errorf("%s resolves to private or local address %s; refusing (SSRF)", host, ip)
47 } 62 }
48 } 63 }
49 return nil 64 return nil
50} 65}
51 66
67// cgnat is the shared address space of RFC 6598, which carriers and
68// overlay networks such as Tailscale use as private space.
69var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
70
52func isForbidden(ip net.IP) bool { 71func isForbidden(ip net.IP) bool {
53 return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || 72 return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
54 ip.IsLinkLocalMulticast() || ip.IsUnspecified() 73 ip.IsMulticast() || ip.IsUnspecified() || cgnat.Contains(ip)
55} 74}
56 75
57type Deliverer struct { 76type Deliverer struct {
internal/webhook/webhook_test.go added +34
@@ -0,0 +1,34 @@
1package webhook
2
3import (
4 "net"
5 "strings"
6 "testing"
7)
8
9func TestCheckAddrs(t *testing.T) {
10 public := []net.IP{net.ParseIP("203.0.113.5")}
11 mixed := []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("10.1.2.3")}
12 if err := CheckAddrs("git.example", public, false); err != nil {
13 t.Fatalf("public: %v", err)
14 }
15 if err := CheckAddrs("git.example", mixed, false); err == nil || !strings.Contains(err.Error(), "10.1.2.3") {
16 t.Fatalf("mixed: %v", err)
17 }
18 if err := CheckAddrs("git.example", mixed, true); err != nil {
19 t.Fatalf("allow_local: %v", err)
20 }
21}
22
23func TestIsForbiddenCGNATAndMulticast(t *testing.T) {
24 for _, s := range []string{"100.64.0.1", "100.127.255.254", "224.0.0.251", "239.1.2.3", "ff02::1", "ff0e::1"} {
25 if !isForbidden(net.ParseIP(s)) {
26 t.Errorf("%s allowed", s)
27 }
28 }
29 for _, s := range []string{"100.63.255.255", "100.128.0.1", "203.0.113.5", "2001:db8::1"} {
30 if isForbidden(net.ParseIP(s)) {
31 t.Errorf("%s refused", s)
32 }
33 }
34}