Commit 869d5659fb

869d5659fb7a7e7259e1aa5f3aa4df71b58fa204

parent: 0babae2db0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:45 UTC

mirror: check the address before each sync and pin git to it

Closes #279

Layout: unified · split

.gitbay/wiki/API.org +3 −3
@@ -197,6 +197,6 @@ A 2xx within 10 seconds is success. Anything else retries with
197197exponential backoff (30s base, doubling) and dead-letters after five
198198attempts; =webhook deliveries= shows the trail and =redeliver= revives a
199199dead letter. Redirects are never followed, and targets resolving to
200loopback/private/link-local addresses are refused both at registration
201and again at connect time, unless the instance sets
202=[webhooks] allow_local=.
200loopback, private, shared (100.64.0.0/10), link-local or multicast
201addresses are refused both at registration and again at connect time,
202unless the instance sets =[webhooks] allow_local=.
.gitbay/wiki/Admin.org +7 −2
@@ -174,7 +174,8 @@ push=.
174174 means no credential-bearing HTTP endpoint exists at all.
175175
176176** [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
178179 addresses. Leave off unless you know why you need it (SSRF).
179180
180181** [limits]
@@ -198,7 +199,11 @@ push=.
198199** [mirrors]
199200- =pull_interval_minutes= (15) — how often pull mirrors fetch their
200201 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.
202207
203208** [go_import]
204209Vanity 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.
5959| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
6060| 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=) |
6161| 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=) |
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 |
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 | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
6464| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
6565
6666* Host firewall
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
@@ -75,7 +75,7 @@ Who may do what:
7575| Integration | Trigger | Security properties |
7676|-------------+----------------------+--------------------------------------------------------------------------------|
7777| 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=) |
7979| Dependency checks | schedule, opt-in | fixed registry hosts; package names restricted (=internal/deps/registry.go=) |
8080
8181* 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.
7575
7676| Control | Status | Evidence |
7777|---------------------------------------------+----------+------------------------------------------------------------------|
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 | in place | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=) |
7979| Webhook payload integrity | in place | HMAC-SHA256 header |
8080| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) |
8181| 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
@@ -19,7 +19,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1919| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high |
2020| #274 | Backups | The local backup archive is not encrypted | medium |
2121| #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 |
2322| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
2423| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2524
.gitbay/wiki/Threat-Model.org +6 −4
@@ -88,10 +88,12 @@ Anything that makes the *server* open an outbound connection to a
8888user-supplied address — webhook delivery, GitHub-history import
8989=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
9090must be http/https and, unless =webhooks.allow_local= is set, the
91resolved address must not be loopback, private, or link-local. The
92webhook dialer re-checks at connect time so a DNS answer that changes
93after validation still cannot reach private space. Redirects are never
94followed.
91resolved address must not be loopback, private, shared
92(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
93at connect time, and the mirror worker resolves and checks before each
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.
9597
9698* Rendering pushed markup
9799
CHANGELOG.org +7
@@ -39,6 +39,13 @@ must add =--scope full=. Existing tokens keep their scope.
3939 not offer STARTTLS gets no mail unless =mail.require_tls = false=
4040 restores the old behaviour. =mail.tls = "implicit"= speaks TLS from
4141 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. Needs git 2.37 or later (#279).
47- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
48 address are refused, as private addresses are (#279).
4249
4350* v1.36.0 — 2026-09-23
4451
internal/mirror/mirror.go +66 −8
@@ -9,15 +9,19 @@ import (
99 "context"
1010 "fmt"
1111 "log/slog"
12 "net"
13 "net/url"
1214 "os"
1315 "os/exec"
1416 "path/filepath"
17 "strings"
1518 "time"
1619
1720 "gitbay.org/gitbay/internal/config"
1821 "gitbay.org/gitbay/internal/control"
1922 "gitbay.org/gitbay/internal/store"
2023 "gitbay.org/gitbay/internal/toolpath"
24 "gitbay.org/gitbay/internal/webhook"
2125)
2226
2327const askpassScript = `#!/bin/sh
@@ -31,6 +35,8 @@ type Worker struct {
3135 St *store.Store
3236 Cfg config.Config
3337 Tick time.Duration
38 // Lookup resolves a mirror's host immediately before each sync.
39 Lookup func(ctx context.Context, host string) ([]net.IP, error)
3440}
3541
3642func New(st *store.Store, cfg config.Config) *Worker {
@@ -40,7 +46,10 @@ func New(st *store.Store, cfg config.Config) *Worker {
4046 tick = d
4147 }
4248 }
43 return &Worker{St: st, Cfg: cfg, Tick: tick}
49 return &Worker{St: st, Cfg: cfg, Tick: tick,
50 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
51 return net.DefaultResolver.LookupIP(ctx, "ip", host)
52 }}
4453}
4554
4655func (w *Worker) Run(ctx context.Context) {
@@ -79,6 +88,30 @@ func (w *Worker) sync(m store.Mirror) error {
7988 return err
8089 }
8190 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
91 u, err := url.Parse(m.URL)
92 if err != nil {
93 return err
94 }
95 if u.Scheme != "https" && u.Scheme != "http" {
96 return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme)
97 }
98
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
100 defer cancel()
101 // The URL was checked when saved, but DNS can answer differently
102 // now. Check what it resolves to at sync time, then let git connect
103 // to exactly those addresses.
104 ips, err := w.Lookup(ctx, u.Hostname())
105 if err != nil {
106 return fmt.Errorf("resolving %s: %w", u.Hostname(), err)
107 }
108 if len(ips) == 0 {
109 // An empty resolve list would leave curl to resolve the host itself.
110 return fmt.Errorf("%s resolves to no address", u.Hostname())
111 }
112 if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil {
113 return err
114 }
82115
83116 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root}
84117 if m.Token != "" {
@@ -96,16 +129,14 @@ func (w *Worker) sync(m store.Mirror) error {
96129 "GITBAY_MIRROR_TOKEN="+m.Token)
97130 }
98131
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
100 defer cancel()
101 var args []string
132 args := append(pinArgs(u, ips), "-C", dir)
102133 if m.Direction == "push" {
103134 // Branches and tags only: internal refs (merge-requests) stay home.
104 args = []string{"-C", dir, "push", "--prune", m.URL,
105 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
135 args = append(args, "push", "--prune", m.URL,
136 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
106137 } else {
107 args = []string{"-C", dir, "fetch", "--prune", m.URL,
108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
138 args = append(args, "fetch", "--prune", m.URL,
139 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
109140 }
110141 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
111142 cmd.Env = env
@@ -114,3 +145,30 @@ func (w *Worker) sync(m store.Mirror) error {
114145 }
115146 return nil
116147}
148
149// pinArgs keeps git on the addresses just checked: curl's resolve list
150// pins the host, and with redirects off a server cannot send git on to
151// a host nobody checked. An address literal needs no pin.
152func pinArgs(u *url.URL, ips []net.IP) []string {
153 args := []string{"-c", "http.followRedirects=false"}
154 host := u.Hostname()
155 if net.ParseIP(host) != nil {
156 return args
157 }
158 port := u.Port()
159 if port == "" {
160 port = "443"
161 if u.Scheme == "http" {
162 port = "80"
163 }
164 }
165 addrs := make([]string, len(ips))
166 for i, ip := range ips {
167 if ip.To4() == nil {
168 addrs[i] = "[" + ip.String() + "]"
169 } else {
170 addrs[i] = ip.String()
171 }
172 }
173 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
174}
internal/mirror/mirror_test.go added +197
@@ -0,0 +1,197 @@
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 URL passed the check when it was saved; the answer at sync time
115// is what counts.
116func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
117 root := t.TempDir()
118 st, m, _ := local(t, root, "https://mirror.test/x.git")
119 var cfg config.Config
120 cfg.Server.Root = root
121 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
122 return []net.IP{net.ParseIP("10.0.0.7")}, nil
123 }}
124 err := w.sync(m)
125 if err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
126 t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err)
127 }
128}
129
130// A refusal is a sync failure like any other: the sweep records it on
131// the mirror, where repo mirror list shows it.
132func TestSweepRecordsTheRefusal(t *testing.T) {
133 root := t.TempDir()
134 st, m, _ := local(t, root, "https://mirror.test/x.git")
135 var cfg config.Config
136 cfg.Server.Root = root
137 cfg.Mirrors.PullIntervalMinutes = 15
138 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
139 return []net.IP{net.ParseIP("100.64.0.9")}, nil
140 }}
141 w.sweep()
142 ms, err := st.ListMirrors(m.RepoID)
143 if err != nil || len(ms) != 1 {
144 t.Fatalf("mirrors: %v %v", ms, err)
145 }
146 if !strings.Contains(ms[0].LastError, "100.64.0.9") {
147 t.Fatalf("last error = %q", ms[0].LastError)
148 }
149}
150
151func TestSyncRefusesAnEmptyAnswer(t *testing.T) {
152 root := t.TempDir()
153 st, m, _ := local(t, root, "https://mirror.test/x.git")
154 var cfg config.Config
155 cfg.Server.Root = root
156 cfg.Webhooks.AllowLocal = true
157 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
158 return nil, nil
159 }}
160 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "no address") {
161 t.Fatalf("sync = %v, want a refusal", err)
162 }
163}
164
165func TestSyncRefusesANonHTTPScheme(t *testing.T) {
166 root := t.TempDir()
167 st, m, _ := local(t, root, "ssh://mirror.test/x.git")
168 var cfg config.Config
169 cfg.Server.Root = root
170 cfg.Webhooks.AllowLocal = true
171 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
172 t.Fatal("looked up a host for an ssh URL")
173 return nil, nil
174 }}
175 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "not http or https") {
176 t.Fatalf("sync = %v, want a refusal", err)
177 }
178}
179
180func TestPinArgs(t *testing.T) {
181 u, _ := url.Parse("https://git.example/x.git")
182 got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")})
183 want := []string{"-c", "http.followRedirects=false",
184 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
185 if !slices.Equal(got, want) {
186 t.Fatalf("https: %q", got)
187 }
188 u, _ = url.Parse("http://git.example:8080/x.git")
189 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
190 t.Fatalf("http with port: %q", got)
191 }
192 // An address literal is its own resolution; there is nothing to pin.
193 u, _ = url.Parse("https://203.0.113.5/x.git")
194 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
195 t.Fatalf("literal: %q", got)
196 }
197}