mirror: connect only to the address checked at sync time !493
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 | |||
| 197 | exponential backoff (30s base, doubling) and dead-letters after five | 197 | exponential backoff (30s base, doubling) and dead-letters after five |
| 198 | attempts; =webhook deliveries= shows the trail and =redeliver= revives a | 198 | attempts; =webhook deliveries= shows the trail and =redeliver= revives a |
| 199 | dead letter. Redirects are never followed, and targets resolving to | 199 | dead letter. Redirects are never followed, and targets resolving to |
| 200 | loopback/private/link-local addresses are refused both at registration | 200 | loopback, private, shared (100.64.0.0/10), link-local or multicast |
| 201 | and again at connect time, unless the instance sets | 201 | addresses are refused both at registration and again at connect time, |
| 202 | =[webhooks] allow_local=. | 202 | unless 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] |
| 204 | Vanity Go module paths, one per line: ="host/module" = "owner/repo"=. | 211 | Vanity 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 | ||
| 87 | Anything that makes the *server* open an outbound connection to a | 87 | Webhook delivery, GitHub-history import =--api-base= and mirror |
| 88 | user-supplied address — webhook delivery, GitHub-history import | 88 | remotes, which make the *server* open an outbound connection to a |
| 89 | =--api-base=, mirror remotes — passes the same SSRF guard: the scheme | 89 | user-supplied address, pass the same SSRF guard: the scheme |
| 90 | must be http/https and, unless =webhooks.allow_local= is set, the | 90 | must be http/https and, unless =webhooks.allow_local= is set, the |
| 91 | resolved address must not be loopback, private, or link-local. The | 91 | resolved address must not be loopback, private, shared |
| 92 | webhook 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 |
| 93 | after validation still cannot reach private space. Redirects are never | 93 | at connect time, and the mirror worker resolves and checks before each |
| 94 | followed. | 94 | sync and pins git to the checked addresses, so a DNS answer that |
| 95 | changes after validation still cannot reach private space. Redirects | ||
| 96 | are never followed. =repo import --from= is the exception: its clone | ||
| 97 | checks the scheme but not the address, and follows git's default | ||
| 98 | redirect 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 | ||
| 23 | const askpassScript = `#!/bin/sh | 28 | const 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 | ||
| 36 | func New(st *store.Store, cfg config.Config) *Worker { | 46 | func 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 | ||
| 46 | func (w *Worker) Run(ctx context.Context) { | 59 | func (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. | ||
| 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 added +259
| @@ -0,0 +1,259 @@ | |||
| 1 | package mirror | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 21 | func 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. | ||
| 36 | func 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. | ||
| 57 | func 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. | ||
| 90 | func 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. | ||
| 116 | func 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. | ||
| 141 | func 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 | |||
| 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 | ||
| 177 | // is what counts. | ||
| 178 | func 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. | ||
| 194 | func 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 | |||
| 213 | func 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 | |||
| 227 | func 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 | |||
| 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 | } | ||
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. | ||
| 26 | func ValidateURL(raw string, allowLocal bool) error { | 27 | func 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. | ||
| 55 | func 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. | ||
| 69 | var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} | ||
| 70 | |||
| 52 | func isForbidden(ip net.IP) bool { | 71 | func 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 | ||
| 57 | type Deliverer struct { | 76 | type Deliverer struct { |
internal/webhook/webhook_test.go added +34
| @@ -0,0 +1,34 @@ | |||
| 1 | package webhook | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func 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 | |||
| 23 | func 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 | } | ||