Commit 592e7bcb92

592e7bcb92db6735547556da02eadca4d6634cdf

parent: ecfc702093

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

cmc <hello@cleberg.net> · 2026-09-29 00:29 UTC

import: document the address check; e2e imports with allow_local

Closes #298

Layout: unified · split

.gitbay/wiki/Admin.org +4 −2
@@ -193,12 +193,14 @@ push=.
193 means no credential-bearing HTTP endpoint exists at all. 193 means no credential-bearing HTTP endpoint exists at all.
194 194
195** [webhooks] 195** [webhooks]
196- =allow_local= (false) — permit webhook and mirror targets on 196- =allow_local= (false) — permit webhook, mirror and import targets on
197 loopback, private, shared (100.64.0.0/10), link-local or multicast 197 loopback, private, shared (100.64.0.0/10), link-local or multicast
198 addresses. Leave off unless you know why you need it (SSRF). 198 addresses. Leave off unless you know why you need it (SSRF).
199 199
200** [limits] 200** [limits]
201- =clone_timeout= (3600s) — cap on =repo import= fetches. 201- =clone_timeout= (3600s) — cap on =repo import= fetches. An import
202 takes http and https URLs only, passes the same address check as a
203 mirror sync and is pinned the same way, so it needs git 2.37 too.
202- =max_blob_bytes= (100MB) — cap on raw file serving over the web. 204- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
203- =max_asset_bytes= (512MB) — cap per uploaded release asset. 205- =max_asset_bytes= (512MB) — cap per uploaded release asset.
204- =max_snippet_bytes= (1MB) — cap per snippet file. 206- =max_snippet_bytes= (1MB) — cap per snippet file.
.gitbay/wiki/Architecture/02-Components.org +1
@@ -32,6 +32,7 @@ the hidden =hook= used by git (=cmd/gitbayd/main.go=,
32| =internal/lfs= | Content-addressed LFS store and HMAC transfer tokens. | 32| =internal/lfs= | Content-addressed LFS store and HMAC transfer tokens. |
33| =internal/webhook= | Outbound webhook delivery with SSRF checks, HMAC signing, retries. | 33| =internal/webhook= | Outbound webhook delivery with SSRF checks, HMAC signing, retries. |
34| =internal/mirror= | Push and pull mirror worker. | 34| =internal/mirror= | Push and pull mirror worker. |
35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. |
35| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. | 36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. |
36| =internal/push= | APNs queue drain and provider-token signing. | 37| =internal/push= | APNs queue drain and provider-token signing. |
37| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). | 38| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). |
.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 and sync, git pinned to the checked address (=internal/mirror/mirror.go=); =repo import --from= has no address check (#298) | 78| SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=) |
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
@@ -14,7 +14,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
17| #298 | SSRF | =repo import --from= fetches without an address check | medium | 17| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium |
18 18
19* Not filed 19* Not filed
20 20
.gitbay/wiki/Threat-Model.org +14 −11
@@ -91,18 +91,21 @@ no inbound HMAC.
91 91
92* Network-facing request forgery 92* Network-facing request forgery
93 93
94Webhook delivery, GitHub-history import =--api-base= and mirror 94Webhook delivery, GitHub-history import =--api-base=, mirror remotes
95remotes, which make the *server* open an outbound connection to a 95and =repo import --from=, which make the *server* open an outbound
96user-supplied address, pass the same SSRF guard: the scheme 96connection to a user-supplied address, pass the same SSRF guard: the
97must be http/https and, unless =webhooks.allow_local= is set, the 97scheme must be http/https and, unless =webhooks.allow_local= is set,
98resolved address must not be loopback, private, shared 98the resolved address must not be loopback, private, shared
99(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks 99(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
100at connect time, and the mirror worker resolves and checks before each 100at connect time, and mirror sync and =repo import= resolve and check
101sync and pins git to the checked addresses, so a DNS answer that 101immediately before running git and pin it to the checked addresses
102changes after validation still cannot reach private space. Redirects 102(=internal/gitpin=), so a DNS answer that changes after validation
103are never followed. =repo import --from= is the exception: its clone 103still cannot reach private space. Redirects are never followed.
104checks the scheme but not the address, and follows git's default 104=repo import= refuses =git://=, which cannot be pinned, and a host
105redirect rule (#298). 105written as a bare number or in hex/octal (=0x7f.1=, =2130706433=,
106=127.1=) rather than dotted decimal, since that form resolves
107differently across parsers. GitHub-history import (=repo
108import-issues --api-base=) is not yet pinned (#301).
106 109
107* Rendering pushed markup 110* Rendering pushed markup
108 111
.gitbay/wiki/Users.org +4
@@ -256,6 +256,10 @@ gitbay repo import-issues you/mirror --from you/repo \
256 --api-base https://codeberg.org/api/v1 # Forgejo: the site's /api/v1 256 --api-base https://codeberg.org/api/v1 # Forgejo: the site's /api/v1
257#+end_src 257#+end_src
258 258
259=repo import= fetches over http and https only, from an address that
260passes the same check as a webhook target; a =git://= URL is refused,
261so use the repository's https URL.
262
259Sourcehut has no API of that shape; =repo import= takes its git data 263Sourcehut has no API of that shape; =repo import= takes its git data
260and the todo.sr.ht tracker is not read. 264and the todo.sr.ht tracker is not read.
261 265
CHANGELOG.org +12
@@ -11,6 +11,18 @@ anything beyond "replace the binary and restart" is needed.
11 listings and shell history. A script that passed the value must pipe 11 listings and shell history. A script that passed the value must pipe
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -= 12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=
13 (#284). 13 (#284).
14- =repo import --from= takes http and https URLs only; =git://= is
15 refused, since its connection cannot be held to a checked address.
16 The host is resolved and checked like a mirror's, git connects only
17 to the checked addresses with redirects off, and the system and
18 global gitconfig are ignored. A source that redirects (a renamed
19 repository) fails; import from the URL it redirects to. Needs git
20 2.37 or later on the server (#298).
21*Upgrade note.* =repo import= and mirror sync now refuse a =git://=
22source and a =--from=/remote URL carrying a query or fragment. A
23mirror or import whose host is written numerically (=127.1=,
24=2130706433=, =0x7f.1=) rather than as a dotted address is refused
25too; rewrite it before upgrading.
14- The builds page's status badge section gives an org-mode snippet 26- The builds page's status badge section gives an org-mode snippet
15 beside the Markdown one, for a README.org (#299). 27 beside the Markdown one, for a README.org (#299).
16- API tokens on the settings page: create with a scope and optional 28- API tokens on the settings page: create with a scope and optional
e2e/import_test.go +4 −12
@@ -11,7 +11,7 @@ import (
11 11
12func TestRepoImport(t *testing.T) { 12func TestRepoImport(t *testing.T) {
13 t.Parallel() 13 t.Parallel()
14 inst := startInstance(t) 14 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
15 aliceKey := inst.newKey(t, "alice") 15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", 16 inst.admin(t, "admin", "user", "create", "alice",
17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") 17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
@@ -82,15 +82,6 @@ func TestRepoImport(t *testing.T) {
82 t.Fatalf("hooks not wired on imported repo:\n%s", pushOut) 82 t.Fatalf("hooks not wired on imported repo:\n%s", pushOut)
83 } 83 }
84 84
85 // Import over git:// too.
86 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/src", "on"); code != 0 {
87 t.Fatalf("git-daemon on: %s", errOut)
88 }
89 gitURL := fmt.Sprintf("git://127.0.0.1:%d/alice/src.git", inst.gitPort)
90 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "import", "alice/mirror2", "--from", gitURL); code != 0 {
91 t.Fatalf("git:// import: %s", errOut)
92 }
93
94 // Org-owned imports: allowed for org admins, refused for non-members. 85 // Org-owned imports: allowed for org admins, refused for non-members.
95 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "imports"); code != 0 { 86 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "imports"); code != 0 {
96 t.Fatalf("org create: %s", errOut) 87 t.Fatalf("org create: %s", errOut)
@@ -102,12 +93,13 @@ func TestRepoImport(t *testing.T) {
102 t.Fatalf("org import log: %d\n%s", code, out) 93 t.Fatalf("org import log: %d\n%s", code, out)
103 } 94 }
104 95
105 // Refusals: bad scheme, credentials in URL, existing name, foreign owner. 96 // Refusals: bad scheme, git://, credentials in URL, existing name, foreign owner.
106 cases := []struct { 97 cases := []struct {
107 args []string 98 args []string
108 want string 99 want string
109 }{ 100 }{
110 {[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "https://, http://, and git://"}, 101 {[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "http:// and https:// only"},
102 {[]string{"repo", "import", "alice/x", "--from", "git://127.0.0.1:1/alice/src.git"}, "use the repository's https:// URL"},
111 {[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"}, 103 {[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"},
112 {[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"}, 104 {[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"},
113 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "not you and not an organization"}, 105 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "not you and not an organization"},