Follow-ups review fixes: LFS key pin, mirror numeric hosts, visibility re-auth, docs !514

merged merged by cmc on 2026-09-29 02:39 UTC · krz/gitbay:followups-fixes into main

21 files changed, +220 −74

Layout: unified · split

.gitbay/wiki/Admin.org +5 −1
@@ -934,11 +934,15 @@ other repositories' builds queue until =-repos= is removed again, which
934934is a pause, not an outage.
935935
936936#+begin_src sh
937# on a machine with an admin gitbay identity (root on the host has none),
938# with the runner's public key copied from the host:
937939gitbay repo create cmc/runner-scratch # then push a .gitbay/ci.yml naming the image
940scp -P 2222 root@<host>:/var/lib/gitbay-runner/.ssh/id_ed25519.pub runner.pub
941gitbay repo runner add cmc/runner-scratch < runner.pub
938942# on the host:
939gitbay repo runner add cmc/runner-scratch < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
940943sed -i 's#^ExecStart=/usr/local/bin/gitbay-runner #&-repos cmc/runner-scratch #' /etc/systemd/system/gitbay-runner.service.d/override.conf
941944systemctl daemon-reload && systemctl restart gitbay-runner
945# back on the admin machine:
942946gitbay build log cmc/runner-scratch 1 # green: remove -repos, redeploy, delete the scratch repository
943947#+end_src
944948
.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −1
@@ -121,7 +121,8 @@ button and the displayed status (=internal/control/mr.go=):
121121- Commands that create a credential (SSH, deploy and runner keys, API
122122 tokens, email verification, login links, PGP keys, device tokens) or
123123 grant access (repository and organization roles, teams, transfers,
124 admin promote and enable, webhooks, secrets, mirrors) are refused
124 repository visibility, admin promote and enable, webhooks, secrets,
125 mirrors) are refused
125126 from a browser session that signed in more than 15 minutes ago
126127 (=control.ReauthWindow=, =Command.NeedsRecentSignIn=). The sign-in
127128 time is =web_sessions.created_at=, which idle renewal does not move;
.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, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=) |
78| SSRF protection on user-supplied URLs | partial | webhooks at save and connect; mirrors at save and sync, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=); =repo import-issues --api-base= checked once and fetched unpinned (#301) |
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
@@ -13,7 +13,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1313| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high |
1414| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
1515| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium |
16| #301 | SSRF | =repo import-issues --api-base= checks the address once; fetches unpinned | medium |
1717
1818* Not filed
1919
.gitbay/wiki/Threat-Model.org +5 −4
@@ -102,10 +102,11 @@ at connect time, and mirror sync and =repo import= resolve and check
102102immediately before running git and pin it to the checked addresses
103103(=internal/gitpin=), so a DNS answer that changes after validation
104104still cannot reach private space. Redirects are never followed.
105=repo import= refuses =git://=, which cannot be pinned, and a host
106written as a bare number or in hex/octal (=0x7f.1=, =2130706433=,
107=127.1=) rather than dotted decimal, since that form resolves
108differently across parsers. GitHub-history import (=repo
105=repo import= refuses =git://=, which cannot be pinned. Mirror sync
106and =repo import= refuse a host written as a bare number or in
107hex/octal (=0x7f.1=, =2130706433=, =127.1=) rather than dotted
108decimal, since that form resolves differently across parsers; =repo
109mirror add= refuses it when the mirror is saved. GitHub-history import (=repo
109110import-issues --api-base=) is not yet pinned (#301).
110111
111112* Rendering pushed markup
.gitbay/wiki/Users.org +2 −1
@@ -702,7 +702,8 @@ or =--all= ends them from the terminal, which is where a lost laptop is
702702handled.
703703
704704Actions that create a credential or grant access (adding a key, token
705or email, org and repository roles, transfers) ask you to sign in again
705or email, org and repository roles, transfers, a repository's
706visibility) ask you to sign in again
706707when your web sign-in is older than 15 minutes. The form shows a "Sign
707708in again" link and the login returns to the page. Idle renewal does not
708709extend this window.
CHANGELOG.org +27 −16
@@ -9,8 +9,9 @@ anything beyond "replace the binary and restart" is needed.
99- =webhook add= reads the signing secret from stdin with =--secret -=;
1010 a value on the command line is refused, since argv shows in process
1111 listings and shell history. A script that passed the value must pipe
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=
13 (#284).
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=.
13 The iOS app's webhook add breaks until it sends the secret on stdin
14 (krz/gitbay-ios#21) (#284).
1415- =repo import --from= takes http and https URLs only; =git://= is
1516 refused, since its connection cannot be held to a checked address.
1617 The host is resolved and checked like a mirror's, git connects only
@@ -18,21 +19,16 @@ anything beyond "replace the binary and restart" is needed.
1819 global gitconfig are ignored. A source that redirects (a renamed
1920 repository) fails; import from the URL it redirects to. Needs git
2021 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.
26- A web session older than 15 minutes cannot mint a credential or grant
27 access — keys, PGP keys, tokens, org membership, and the admin
28 promote/enable actions — and the form it tried shows a sign-in link
29 that returns there (#297).
22- =repo mirror add= refuses a host written numerically (=127.1=,
23 =2130706433=, =0x7f.1=) when the mirror is added, rather than at its
24 first sync (#298).
3025- A browser session creates credentials and grants access — keys, PGP
3126 keys, tokens, verified addresses, org and repository roles, transfers,
32 webhooks, secrets, mirrors, and the admin promote/enable actions —
33 only within 15 minutes of signing in. An older session gets the form
34 back with a "Sign in again" link, and the login returns to it. SSH and
35 API tokens are unaffected (#297).
27 repository visibility, webhooks, secrets, mirrors, and the admin
28 promote/enable and repository-visibility actions — only within 15
29 minutes of signing in. An
30 older session gets the form back with a "Sign in again" link, and the
31 login returns to it. SSH and API tokens are unaffected (#297).
3632- The builds page's status badge section gives an org-mode snippet
3733 beside the Markdown one, for a README.org (#299).
3834- API tokens on the settings page: create with a scope and optional
@@ -95,6 +91,12 @@ entries; if extracting one leaves a repository's =refs/= directory
9591missing, =gitbayd admin backup --verify <archive>= names it, and
9692=mkdir -p <root>/repos/<owner>/<name>.git/refs= fixes it.
9793
94*Upgrade note.* =repo import= now refuses a =git://= source and a
95=--from= URL carrying a query or fragment. Mirrors and imports whose
96host is written numerically (=127.1=, =2130706433=, =0x7f.1=) rather
97than as a dotted address are refused too; rewrite the URL before
98upgrading (#298).
99
98100- A token with a =--ttl= is refused on every command that creates a
99101 credential: tokens, keys, deploy keys, runner keys, login links,
100102 invites, accounts and verified addresses (#257).
@@ -294,12 +296,21 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
294296 hour (#285). *Operators:* tokens minted before the upgrade are
295297 refused. git-lfs asks =git-lfs-authenticate= for a token each time
296298 it runs, so only a transfer running across the restart fails, with
297 "repository not found"; running the command again fixes it.
299 "repository not found"; running the command again fixes it. A
300 download from a public repository that presents a token issued
301 before the upgrade is refused the same way, not served anonymously,
302 and needs the same rerun.
298303- Every LFS request also repeats the repository check for the token's
299304 key: a collaborator whose access is revoked or reduced, or a reader
300305 of a public repository made private, loses the token's use with the
301306 access, and an upload token stops working once its repository is
302307 archived (#285).
308- LFS transfer tokens carry a hash of their key's fingerprint beside
309 its id, and a token whose key id now belongs to another key is
310 refused, since SQLite gives a new key the id of the last deleted one.
311 The token format changed: tokens minted before the upgrade are
312 refused, and a transfer running across the restart needs running
313 again (#303).
303314
304315* v1.36.0 — 2026-09-23
305316
internal/control/admin.go +5 −4
@@ -80,10 +80,11 @@ func init() {
8080 Examples: []string{"admin repo unarchive alice/old-project"},
8181 Run: runAdminRepoUnarchive})
8282 register(Command{Path: []string{"admin", "repo", "visibility"},
83 Summary: "set any repository's visibility (instance admins; audited)",
84 Usage: "admin repo visibility <owner/name> public|private",
85 Examples: []string{"admin repo visibility alice/secret private"},
86 Run: runAdminRepoVisibility})
83 NeedsRecentSignIn: true,
84 Summary: "set any repository's visibility (instance admins; audited)",
85 Usage: "admin repo visibility <owner/name> public|private",
86 Examples: []string{"admin repo visibility alice/secret private"},
87 Run: runAdminRepoVisibility})
8788 register(Command{Path: []string{"admin", "repo", "delete"},
8889 Summary: "delete any repository (instance admins; audited)",
8990 Usage: "admin repo delete <owner/name> --yes",
internal/control/control_test.go +2 −1
@@ -180,7 +180,8 @@ func TestStdinCommandsReadStdin(t *testing.T) {
180180 for _, cmd := range Commands() {
181181 u := cmd.Usage
182182 wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") ||
183 strings.Contains(u, "stdin") || strings.Contains(u, "--key -")
183 strings.Contains(u, "stdin") || strings.Contains(u, "--key -") ||
184 strings.Contains(u, "--secret -")
184185 if wants && !cmd.ReadsStdin {
185186 t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u)
186187 }
internal/control/mirrorcmd.go +9
@@ -5,9 +5,11 @@ import (
55 "errors"
66 "fmt"
77 "io"
8 "net/url"
89 "strconv"
910 "strings"
1011
12 "gitbay.org/gitbay/internal/gitpin"
1113 "gitbay.org/gitbay/internal/policy"
1214 "gitbay.org/gitbay/internal/protocol"
1315 "gitbay.org/gitbay/internal/store"
@@ -54,6 +56,13 @@ func runMirrorAdd(c *Ctx, args []string) int {
5456 if path == "" || urlArg == "" || (direction != "push" && direction != "pull") {
5557 return c.usage()
5658 }
59 // Sync refuses a numerically written host; say so now, before a
60 // resolver gets to read it.
61 if u, err := url.Parse(urlArg); err == nil {
62 if err := gitpin.CheckHost(u.Hostname()); err != nil {
63 return c.failInput(err)
64 }
65 }
5766 // The worker's git process dials this URL from the server: same SSRF
5867 // surface as a webhook target, same rules.
5968 if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil {
internal/control/mirrorcmd_test.go added +29
@@ -0,0 +1,29 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10// mirror add refuses a host sync would refuse as numeric, on an
11// instance that allows local targets or not, and stores nothing (#298).
12func TestMirrorAddRefusesNumericHost(t *testing.T) {
13 for _, allowLocal := range []bool{true, false} {
14 for _, host := range []string{"127.1", "2130706433", "0x7f.1"} {
15 c, errOut, st, _ := importCtx(t, allowLocal)
16 code := Dispatch(c, []string{"repo", "mirror", "add", "alice/app", "https://" + host + "/x.git", "--direction", "pull"})
17 if code != protocol.ExitUsage || !strings.Contains(errOut.String(), "numeric address") {
18 t.Fatalf("%s (allow_local %v): exit %d, %q", host, allowLocal, code, errOut.String())
19 }
20 repo, err := st.RepoByPath("alice/app")
21 if err != nil {
22 t.Fatal(err)
23 }
24 if ms, err := st.ListMirrors(repo.ID); err != nil || len(ms) != 0 {
25 t.Fatalf("%s: mirrors %v, %v", host, ms, err)
26 }
27 }
28 }
29}
internal/control/reauth_test.go +2
@@ -109,6 +109,7 @@ func TestNeedsRecentSignInSet(t *testing.T) {
109109 want := []string{
110110 "admin email verify",
111111 "admin invite",
112 "admin repo visibility",
112113 "admin user create",
113114 "admin user enable",
114115 "admin user promote",
@@ -125,6 +126,7 @@ func TestNeedsRecentSignInSet(t *testing.T) {
125126 "repo mirror add",
126127 "repo runner add",
127128 "repo secret set",
129 "repo settings visibility",
128130 "repo transfer",
129131 "token create",
130132 "web login",
internal/control/repo.go +3 −1
@@ -118,7 +118,9 @@ func init() {
118118 Summary: "set repository visibility",
119119 Usage: "repo settings visibility <owner/name> public|private",
120120 Examples: []string{"repo settings visibility krz/gitbay public"},
121 Run: runSetVisibility})
121 // Making a repository public shows it to everyone.
122 NeedsRecentSignIn: true,
123 Run: runSetVisibility})
122124 register(Command{Path: []string{"repo", "settings", "website"},
123125 Summary: "set the repository website",
124126 Usage: "repo settings website <owner/name> <url> ('' clears)",
internal/control/webhook.go +1 −1
@@ -87,7 +87,7 @@ func runWebhookAdd(c *Ctx, args []string) int {
8787 if err != nil {
8888 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
8989 }
90 secret = strings.TrimRight(string(raw), "\n")
90 secret = strings.TrimRight(string(raw), "\r\n")
9191 if secret == "" {
9292 return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)")
9393 }
internal/control/webhook_test.go +7 −3
@@ -70,11 +70,15 @@ func TestWebhookAddSecretFromStdin(t *testing.T) {
7070 if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK {
7171 t.Fatalf("no secret: exit %d, %q", code, msg)
7272 }
73 // A secret from a file with CRLF line endings loses the \r too.
74 if msg, code := run("crlf\r\n", "webhook", "add", repo.Path(), "http://127.0.0.1/crlf", "--secret", "-"); code != protocol.ExitOK {
75 t.Fatalf("CRLF secret: exit %d, %q", code, msg)
76 }
7377 hooks, err := st.ListWebhooks(repo.ID)
74 if err != nil || len(hooks) != 2 {
78 if err != nil || len(hooks) != 3 {
7579 t.Fatalf("hooks: %+v %v", hooks, err)
7680 }
77 if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" {
78 t.Fatalf("secrets: %q, %q", hooks[0].Secret, hooks[1].Secret)
81 if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" || hooks[2].Secret != "crlf" {
82 t.Fatalf("secrets: %q, %q, %q", hooks[0].Secret, hooks[1].Secret, hooks[2].Secret)
7983 }
8084}
internal/gitpin/gitpin.go +13 −4
@@ -46,10 +46,8 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R
4646 if host == "" {
4747 return Remote{}, fmt.Errorf("URL has no host")
4848 }
49 if net.ParseIP(host) == nil && numericHost(host) {
50 // 127.1, 2130706433 and 0x7f.1 are loopback to curl's parser
51 // but not to Go's; refuse rather than leave them to a resolver.
52 return Remote{}, fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host)
49 if err := CheckHost(host); err != nil {
50 return Remote{}, err
5351 }
5452 ips, err := lookup(ctx, host)
5553 if err != nil {
@@ -65,6 +63,17 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R
6563 return Remote{URL: u, IPs: ips}, nil
6664}
6765
66// CheckHost refuses a host written as a number in a form other than
67// an IP literal: 127.1, 2130706433 and 0x7f.1 are loopback to curl's
68// parser but not to Go's, so they are refused rather than left to a
69// resolver.
70func CheckHost(host string) error {
71 if net.ParseIP(host) == nil && numericHost(host) {
72 return fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host)
73 }
74 return nil
75}
76
6877// numericHost reports whether every label of host is a decimal, octal
6978// or hex number, the shapes inet_aton reads as an IPv4 address.
7079func numericHost(host string) bool {
internal/httpd/lfs.go +17 −6
@@ -41,7 +41,8 @@ func (s *Server) lfsSecret() ([]byte, error) {
4141// for none). A token is bound to the SSH key that obtained it and
4242// works only while that key is registered, unexpired and on an enabled
4343// account, and while the key still has the access its operation needs
44// on the repo (#285). Without one, public repos allow anonymous
44// on the repo (#285). A key that took over a deleted key's id does not
45// match the token's fingerprint pin (#303). Without one, public repos allow anonymous
4546// download only.
4647func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
4748 auth := r.Header.Get("Authorization")
@@ -62,7 +63,7 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
6263 return "", 0
6364 }
6465 live, err := s.st.LiveSSHKeys([]int64{g.KeyID})
65 if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") {
66 if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, g.KeyPin, repo, g.Op == "upload") {
6667 return "", 0
6768 }
6869 return g.Op, g.KeyID
@@ -75,13 +76,14 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
7576
7677// lfsKeyAllows repeats git-lfs-authenticate's access check for the key
7778// now: a deploy key by its binding, any other key by its account's
78// access narrowed by the key's scope. An archived repo takes no uploads.
79func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool {
79// access narrowed by the key's scope. The key must be the one the token
80// was minted for, by fingerprint pin. An archived repo takes no uploads.
81func (s *Server) lfsKeyAllows(keyID int64, pin string, repo store.Repo, write bool) bool {
8082 if write && repo.Settings.Archived {
8183 return false
8284 }
8385 key, err := s.st.SSHKeyByID(keyID)
84 if err != nil {
86 if err != nil || lfs.KeyPin(key.Fingerprint) != pin {
8587 return false
8688 }
8789 if policy.IsDeployScope(key.Scope) {
@@ -171,7 +173,16 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
171173 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
172174 return
173175 }
174 transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
176 fingerprint := ""
177 if keyID != 0 {
178 key, err := s.st.SSHKeyByID(keyID)
179 if err != nil {
180 lfsError(w, http.StatusNotFound, "repository not found")
181 return
182 }
183 fingerprint = key.Fingerprint
184 }
185 transferToken := lfs.Sign(secret, repo.ID, keyID, fingerprint, req.Operation, time.Now())
175186 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
176187 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
177188 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
internal/httpd/lfsauth_test.go +44 −16
@@ -54,14 +54,14 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) {
5454 }
5555
5656 live := addKey("SHA256:live", nil)
57 tok := lfs.Sign(secret, repo.ID, live, "upload", time.Now())
57 tok := lfs.Sign(secret, repo.ID, live, "SHA256:live", "upload", time.Now())
5858 if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live {
5959 t.Fatalf("live key: %q, %d", op, key)
6060 }
6161
6262 past := time.Now().Add(-time.Minute)
6363 expired := addKey("SHA256:expired", &past)
64 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "upload", time.Now())), repo); op != "" {
64 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "SHA256:expired", "upload", time.Now())), repo); op != "" {
6565 t.Errorf("expired key: %q", op)
6666 }
6767
@@ -73,7 +73,7 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) {
7373 }
7474
7575 other := addKey("SHA256:other", nil)
76 otherTok := lfs.Sign(secret, repo.ID, other, "download", time.Now())
76 otherTok := lfs.Sign(secret, repo.ID, other, "SHA256:other", "download", time.Now())
7777 if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" {
7878 t.Fatalf("second key before disable: %q", op)
7979 }
@@ -97,13 +97,13 @@ func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) {
9797 t.Fatal(err)
9898 }
9999 now := time.Now()
100 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "download", now)), pub); op != "download" {
100 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "download", now)), pub); op != "download" {
101101 t.Errorf("public download: %q", op)
102102 }
103 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "upload", now)), pub); op != "" {
103 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "upload", now)), pub); op != "" {
104104 t.Errorf("anonymous upload: %q", op)
105105 }
106 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "download", now)), priv); op != "" {
106 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "", "download", now)), priv); op != "" {
107107 t.Errorf("private download: %q", op)
108108 }
109109}
@@ -140,7 +140,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
140140 t.Fatal(err)
141141 }
142142 bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full")
143 up := lfs.Sign(secret, repo.ID, bobKey, "upload", now)
143 up := lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "upload", now)
144144 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" {
145145 t.Fatalf("collaborator upload: %q", op)
146146 }
@@ -153,7 +153,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
153153 if err := st.RevokeAccess(repo.ID, bob); err != nil {
154154 t.Fatal(err)
155155 }
156 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "download", now)), repo); op != "" {
156 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "download", now)), repo); op != "" {
157157 t.Errorf("download after access was revoked: %q", op)
158158 }
159159
@@ -163,7 +163,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
163163 t.Fatal(err)
164164 }
165165 carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full")
166 down := lfs.Sign(secret, pub.ID, carolKey, "download", now)
166 down := lfs.Sign(secret, pub.ID, carolKey, "SHA256:carol", "download", now)
167167 if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" {
168168 t.Fatalf("public download: %q", op)
169169 }
@@ -194,12 +194,12 @@ func TestLFSDeployKeyToken(t *testing.T) {
194194 ro := fmt.Sprintf("deploy:%d:ro", repo.ID)
195195
196196 live := lfsTestKey(t, st, u.ID, "SHA256:deploy-live", rw)
197 if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "upload", now)), repo); op != "upload" || key != live {
197 if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "upload", now)), repo); op != "upload" || key != live {
198198 t.Fatalf("live deploy key: %q, %d", op, key)
199199 }
200200
201201 removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw)
202 tok := lfs.Sign(secret, repo.ID, removed, "download", now)
202 tok := lfs.Sign(secret, repo.ID, removed, "SHA256:deploy-removed", "download", now)
203203 if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil {
204204 t.Fatal(err)
205205 }
@@ -208,17 +208,17 @@ func TestLFSDeployKeyToken(t *testing.T) {
208208 }
209209
210210 readOnly := lfsTestKey(t, st, u.ID, "SHA256:deploy-ro", ro)
211 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "download", now)), repo); op != "download" {
211 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "download", now)), repo); op != "download" {
212212 t.Errorf("read-only deploy key download: %q", op)
213213 }
214 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "upload", now)), repo); op != "" {
214 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "upload", now)), repo); op != "" {
215215 t.Errorf("read-only deploy key upload: %q", op)
216216 }
217217
218218 if err := st.SetUserDisabled(u.ID, true); err != nil {
219219 t.Fatal(err)
220220 }
221 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "download", now)), repo); op != "" {
221 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "download", now)), repo); op != "" {
222222 t.Errorf("deploy key of a disabled account: %q", op)
223223 }
224224}
@@ -234,7 +234,7 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) {
234234 }
235235 key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full")
236236 now := time.Now()
237 up := lfs.Sign(secret, repo.ID, key, "upload", now)
237 up := lfs.Sign(secret, repo.ID, key, "SHA256:owner", "upload", now)
238238 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" {
239239 t.Fatalf("upload before archiving: %q", op)
240240 }
@@ -248,7 +248,35 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) {
248248 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" {
249249 t.Errorf("upload after archiving: %q", op)
250250 }
251 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "download", now)), repo); op != "download" {
251 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "SHA256:owner", "download", now)), repo); op != "download" {
252252 t.Errorf("download after archiving: %q", op)
253253 }
254254}
255
256// SQLite gives a new key the id of the highest deleted one. A token
257// minted for the deleted key is refused when presented against the new
258// key; the new key's own token works (#303).
259func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) {
260 s, st, u := newTokenTestServer(t)
261 repo := lfsTestRepo(t, st, u.ID, "app", "private")
262 secret, err := s.lfsSecret()
263 if err != nil {
264 t.Fatal(err)
265 }
266 now := time.Now()
267 oldID := lfsTestKey(t, st, u.ID, "SHA256:old", "full")
268 old := lfs.Sign(secret, repo.ID, oldID, "SHA256:old", "upload", now)
269 if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil {
270 t.Fatal(err)
271 }
272 newID := lfsTestKey(t, st, u.ID, "SHA256:new", "full")
273 if newID != oldID {
274 t.Fatalf("new key got id %d, want the reused %d", newID, oldID)
275 }
276 if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" {
277 t.Errorf("old key's token on the reused id: %q", op)
278 }
279 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, newID, "SHA256:new", "upload", now)), repo); op != "upload" {
280 t.Errorf("new key's own token: %q", op)
281 }
282}
internal/lfs/lfs.go +27 −9
@@ -129,24 +129,39 @@ const TokenTTL = time.Hour
129129
130130// Sign mints a token for op ("download" or "upload") on repoID, bound
131131// to keyID: the SSH key, user or deploy, that asked for it, or 0 for an
132// anonymous download of a public repository.
133func Sign(secret []byte, repoID, keyID int64, op string, now time.Time) string {
134 payload := fmt.Sprintf("%d:%d:%s:%d", repoID, keyID, op, now.Add(TokenTTL).Unix())
132// anonymous download of a public repository. fingerprint is that key's
133// fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so
134// the token carries a hash of the fingerprint as well and a new key
135// given the old id does not inherit the old key's tokens (#303).
136func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string {
137 payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix())
135138 mac := hmac.New(sha256.New, secret)
136139 mac.Write([]byte(payload))
137140 return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
138141 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
139142}
140143
144// KeyPin is the fingerprint's form in a token: the first 16 hex
145// characters of its SHA-256, or "" for no key.
146func KeyPin(fingerprint string) string {
147 if fingerprint == "" {
148 return ""
149 }
150 sum := sha256.Sum256([]byte(fingerprint))
151 return hex.EncodeToString(sum[:8])
152}
153
141154// Grant is what a verified token authorizes.
142155type Grant struct {
143156 RepoID int64
144 KeyID int64 // 0: an anonymous download of a public repository
157 KeyID int64 // 0: an anonymous download of a public repository
158 KeyPin string // KeyPin of the key's fingerprint; "" when KeyID is 0
145159 Op string
146160}
147161
148162// Verify checks a token's MAC, shape and expiry. A token from before
149// tokens named their key does not verify.
163// tokens named their key, or before they carried its fingerprint, does
164// not verify.
150165func Verify(secret []byte, token string, now time.Time) (Grant, bool) {
151166 payloadB64, macB64, found := strings.Cut(token, ".")
152167 if !found {
@@ -166,19 +181,22 @@ func Verify(secret []byte, token string, now time.Time) (Grant, bool) {
166181 return Grant{}, false
167182 }
168183 parts := strings.Split(string(payload), ":")
169 if len(parts) != 4 {
184 if len(parts) != 5 {
170185 return Grant{}, false
171186 }
172187 repoID, err1 := strconv.ParseInt(parts[0], 10, 64)
173188 keyID, err2 := strconv.ParseInt(parts[1], 10, 64)
174 exp, err3 := strconv.ParseInt(parts[3], 10, 64)
189 exp, err3 := strconv.ParseInt(parts[4], 10, 64)
175190 if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp {
176191 return Grant{}, false
177192 }
178 if parts[2] != "download" && parts[2] != "upload" {
193 if (keyID == 0) != (parts[2] == "") {
194 return Grant{}, false
195 }
196 if parts[3] != "download" && parts[3] != "upload" {
179197 return Grant{}, false
180198 }
181 return Grant{RepoID: repoID, KeyID: keyID, Op: parts[2]}, true
199 return Grant{RepoID: repoID, KeyID: keyID, KeyPin: parts[2], Op: parts[3]}, true
182200}
183201
184202// NewSecret returns 32 random bytes, hex-encoded for the settings table.
internal/lfs/lfs_test.go +17 −3
@@ -12,9 +12,9 @@ import (
1212func TestTokenCarriesTheKey(t *testing.T) {
1313 secret := []byte("secret")
1414 now := time.Now()
15 tok := Sign(secret, 7, 42, "upload", now)
15 tok := Sign(secret, 7, 42, "SHA256:k", "upload", now)
1616 g, ok := Verify(secret, tok, now)
17 if !ok || g != (Grant{RepoID: 7, KeyID: 42, Op: "upload"}) {
17 if !ok || g != (Grant{RepoID: 7, KeyID: 42, KeyPin: KeyPin("SHA256:k"), Op: "upload"}) {
1818 t.Fatalf("Verify = %+v, %v", g, ok)
1919 }
2020 if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok {
@@ -23,7 +23,7 @@ func TestTokenCarriesTheKey(t *testing.T) {
2323 if _, ok := Verify([]byte("other"), tok, now); ok {
2424 t.Error("a token verified under another secret")
2525 }
26 if g, ok := Verify(secret, Sign(secret, 7, 0, "download", now), now); !ok || g.KeyID != 0 {
26 if g, ok := Verify(secret, Sign(secret, 7, 0, "", "download", now), now); !ok || g.KeyID != 0 || g.KeyPin != "" {
2727 t.Errorf("anonymous grant = %+v, %v", g, ok)
2828 }
2929}
@@ -41,3 +41,17 @@ func TestUnboundTokenRefused(t *testing.T) {
4141 t.Fatalf("a pre-upgrade token verified: %+v", g)
4242 }
4343}
44
45// A token minted before tokens carried the key's fingerprint has four
46// fields. It is refused (#303).
47func TestUnpinnedTokenRefused(t *testing.T) {
48 secret := []byte("secret")
49 payload := fmt.Sprintf("%d:%d:%s:%d", 7, 42, "upload", time.Now().Add(TokenTTL).Unix())
50 mac := hmac.New(sha256.New, secret)
51 mac.Write([]byte(payload))
52 tok := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
53 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
54 if g, ok := Verify(secret, tok, time.Now()); ok {
55 t.Fatalf("an unpinned token verified: %+v", g)
56 }
57}
internal/sshd/lfs.go +1 −1
@@ -69,7 +69,7 @@ func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, key
6969 fmt.Fprintln(stderr, "internal error")
7070 return protocol.ExitFailure
7171 }
72 token := lfs.Sign([]byte(secret), repo.ID, key.ID, op, time.Now())
72 token := lfs.Sign([]byte(secret), repo.ID, key.ID, key.Fingerprint, op, time.Now())
7373 json.NewEncoder(stdout).Encode(map[string]any{
7474 "href": fmt.Sprintf("%s/%s/%s.git/info/lfs",
7575 cfg.Server.SiteURL, repo.OwnerName, repo.Name),