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
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 +9 −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,13 @@ 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; 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.
202209
203210** [go_import]
204211Vanity 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 | 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 −1
@@ -19,7 +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| #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 |
2323| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
2424| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2525
.gitbay/wiki/Threat-Model.org +11 −7
@@ -84,14 +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
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. =repo import --from= is the exception: its clone
97checks the scheme but not the address, and follows git's default
98redirect rule (#298).
9599
96100* Rendering pushed markup
97101
CHANGELOG.org +9
@@ -39,6 +39,15 @@ 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. 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).
4251
4352* v1.36.0 — 2026-09-23
4453
internal/mirror/mirror.go +108 −9
@@ -9,15 +9,20 @@ import (
99 "context"
1010 "fmt"
1111 "log/slog"
12 "net"
13 "net/url"
1214 "os"
1315 "os/exec"
1416 "path/filepath"
17 "strconv"
18 "strings"
1519 "time"
1620
1721 "gitbay.org/gitbay/internal/config"
1822 "gitbay.org/gitbay/internal/control"
1923 "gitbay.org/gitbay/internal/store"
2024 "gitbay.org/gitbay/internal/toolpath"
25 "gitbay.org/gitbay/internal/webhook"
2126)
2227
2328const askpassScript = `#!/bin/sh
@@ -31,6 +36,11 @@ type Worker struct {
3136 St *store.Store
3237 Cfg config.Config
3338 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
3444}
3545
3646func New(st *store.Store, cfg config.Config) *Worker {
@@ -40,10 +50,22 @@ func New(st *store.Store, cfg config.Config) *Worker {
4050 tick = d
4151 }
4252 }
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 }}
4457}
4558
4659func (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 }
4769 t := time.NewTicker(w.Tick)
4870 defer t.Stop()
4971 for {
@@ -64,6 +86,10 @@ func (w *Worker) sweep() {
6486 return
6587 }
6688 for _, m := range due {
89 if w.gitErr != nil {
90 w.St.SetMirrorResult(m.ID, w.gitErr.Error())
91 continue
92 }
6793 if err := w.sync(m); err != nil {
6894 slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err)
6995 w.St.SetMirrorResult(m.ID, err.Error())
@@ -79,8 +105,35 @@ func (w *Worker) sync(m store.Mirror) error {
79105 return err
80106 }
81107 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 }
82115
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"}
84137 if m.Token != "" {
85138 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
86139 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
@@ -96,16 +149,14 @@ func (w *Worker) sync(m store.Mirror) error {
96149 "GITBAY_MIRROR_TOKEN="+m.Token)
97150 }
98151
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
100 defer cancel()
101 var args []string
152 args := append(pinArgs(u, ips), "-C", dir)
102153 if m.Direction == "push" {
103154 // 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/*"}
155 args = append(args, "push", "--prune", m.URL,
156 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
106157 } else {
107 args = []string{"-C", dir, "fetch", "--prune", m.URL,
108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
158 args = append(args, "fetch", "--prune", m.URL,
159 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
109160 }
110161 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
111162 cmd.Env = env
@@ -114,3 +165,51 @@ func (w *Worker) sync(m store.Mirror) error {
114165 }
115166 return nil
116167}
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 (
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
@@ -41,17 +42,35 @@ func ValidateURL(raw string, allowLocal bool) error {
4142 if err != nil {
4243 return fmt.Errorf("cannot resolve %s: %w", u.Hostname(), err)
4344 }
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 }
4459 for _, ip := range ips {
4560 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)
4762 }
4863 }
4964 return nil
5065}
5166
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
5271func isForbidden(ip net.IP) bool {
5372 return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
54 ip.IsLinkLocalMulticast() || ip.IsUnspecified()
73 ip.IsMulticast() || ip.IsUnspecified() || cgnat.Contains(ip)
5574}
5675
5776type 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}