Commit 298ac0b456

298ac0b4566b20e4d790e40ef49ca215342df72a

parent: 869d5659fb

Verified · cmc ci/build: success ci/test: success

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

mirror: ignore operator gitconfig, require git 2.37; import SSRF gap documented

Ref #279

Layout: unified · split

.gitbay/wiki/Admin.org +3 −1
@@ -203,7 +203,9 @@ push=.
203203 and again before every sync; git then connects only to the addresses
204204 that were checked (=http.curloptResolve=) and does not follow
205205 redirects, so a mirror of a renamed repository fails until its URL
206 is updated. Needs git 2.37 or later on the server.
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.
207209
208210** [go_import]
209211Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
.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 | in place | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=) |
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) |
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,6 +19,7 @@ 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| #298 | SSRF | =repo import --from= fetches without an address check | medium |
2223| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
2324| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2425
.gitbay/wiki/Threat-Model.org +6 −4
@@ -84,16 +84,18 @@ no inbound HMAC.
8484
8585* Network-facing request forgery
8686
87Anything that makes the *server* open an outbound connection to a
88user-supplied address — webhook delivery, GitHub-history import
89=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
87Webhook delivery, GitHub-history import =--api-base= and mirror
88remotes, which make the *server* open an outbound connection to a
89user-supplied address, pass the same SSRF guard: the scheme
9090must be http/https and, unless =webhooks.allow_local= is set, the
9191resolved address must not be loopback, private, shared
9292(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
9393at connect time, and the mirror worker resolves and checks before each
9494sync and pins git to the checked addresses, so a DNS answer that
9595changes after validation still cannot reach private space. Redirects
96are never followed.
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).
9799
98100* Rendering pushed markup
99101
CHANGELOG.org +3 −1
@@ -43,7 +43,9 @@ must add =--scope full=. Existing tokens keep their scope.
4343 only to them, with redirects off; a URL that now resolves to private
4444 space, or is not http or https, fails the sync with the reason on
4545 =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).
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).
4749- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
4850 address are refused, as private addresses are (#279).
4951
internal/mirror/mirror.go +42 −1
@@ -14,6 +14,7 @@ import (
1414 "os"
1515 "os/exec"
1616 "path/filepath"
17 "strconv"
1718 "strings"
1819 "time"
1920
@@ -37,6 +38,9 @@ type Worker struct {
3738 Tick time.Duration
3839 // Lookup resolves a mirror's host immediately before each sync.
3940 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
4044}
4145
4246func New(st *store.Store, cfg config.Config) *Worker {
@@ -53,6 +57,15 @@ func New(st *store.Store, cfg config.Config) *Worker {
5357}
5458
5559func (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 }
5669 t := time.NewTicker(w.Tick)
5770 defer t.Stop()
5871 for {
@@ -73,6 +86,10 @@ func (w *Worker) sweep() {
7386 return
7487 }
7588 for _, m := range due {
89 if w.gitErr != nil {
90 w.St.SetMirrorResult(m.ID, w.gitErr.Error())
91 continue
92 }
7693 if err := w.sync(m); err != nil {
7794 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err)
7895 w.St.SetMirrorResult(m.ID, err.Error())
@@ -113,7 +130,10 @@ func (w *Worker) sync(m store.Mirror) error {
113130 return err
114131 }
115132
116 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root}
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"}
117137 if m.Token != "" {
118138 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
119139 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
@@ -172,3 +192,24 @@ func pinArgs(u *url.URL, ips []net.IP) []string {
172192 }
173193 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
174194}
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 +62
@@ -111,6 +111,68 @@ func TestSyncConnectsToTheCheckedAddress(t *testing.T) {
111111 }
112112}
113113
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
114176// The URL passed the check when it was saved; the answer at sync time
115177// is what counts.
116178func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
internal/webhook/webhook.go +7 −6
@@ -20,19 +20,20 @@ import (
2020 "gitbay.org/gitbay/internal/store"
2121)
2222
23// ValidateURL rejects URLs a webhook must not target: non-HTTP schemes and,
24// unless allowLocal, anything resolving to loopback, private, or link-local
25// addresses (SSRF).
23// ValidateURL rejects URLs the server must not connect to (SSRF): non-HTTP
24// schemes and, unless allowLocal, anything resolving to a loopback,
25// private, shared (100.64.0.0/10), link-local, multicast or unspecified
26// address. Webhooks, mirrors and issue import use it.
2627func ValidateURL(raw string, allowLocal bool) error {
2728 u, err := url.Parse(raw)
2829 if err != nil {
2930 return fmt.Errorf("invalid URL: %w", err)
3031 }
3132 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")
3334 }
3435 if u.Hostname() == "" {
35 return fmt.Errorf("webhook URL has no host")
36 return fmt.Errorf("URL has no host")
3637 }
3738 if allowLocal {
3839 return nil
@@ -42,7 +43,7 @@ func ValidateURL(raw string, allowLocal bool) error {
4243 return fmt.Errorf("cannot resolve %s: %w", u.Hostname(), err)
4344 }
4445 if err := CheckAddrs(u.Hostname(), ips, allowLocal); err != nil {
45 return fmt.Errorf("webhook target %s resolves to a private or local address; refusing (SSRF)", u.Hostname())
46 return fmt.Errorf("target %s resolves to a private or local address; refusing (SSRF)", u.Hostname())
4647 }
4748 return nil
4849}