Commit 6f553fea02

6f553fea0242f48ac210df09954d5058717d6bb5

parent: 08a7e44095

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

cmc <hello@cleberg.net> · 2026-09-28 07:57 UTC

web: login over SSH applies the login-link limit

Closes #278

Layout: unified · split

.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -19,7 +19,6 @@ 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| #278 | Login links | =web login= over SSH skips the login-link rate limit | low |
23| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium | 22| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
24| #280 | Mail | STARTTLS only when the relay offers it | medium | 23| #280 | Mail | STARTTLS only when the relay offers it | medium |
25| #281 | TLS | No explicit minimum TLS version | low | 24| #281 | TLS | No explicit minimum TLS version | low |
CHANGELOG.org +2
@@ -31,6 +31,8 @@ must add =--scope full=. Existing tokens keep their scope.
31- Browser sessions end after twelve hours without a request, and after 31- Browser sessions end after twelve hours without a request, and after
32 seven days as before. Sessions open at upgrade get a fresh twelve 32 seven days as before. Sessions open at upgrade get a fresh twelve
33 hours. =web sessions list= shows when each was last used (#276). 33 hours. =web sessions list= shows when each was last used (#276).
34- =web login= over SSH refuses a sixth link in an hour, the same bound
35 the login page's mailed links have (#278).
34 36
35* v1.36.0 — 2026-09-23 37* v1.36.0 — 2026-09-23
36 38
internal/control/loginlink.go +3 −5
@@ -11,11 +11,9 @@ import (
11 11
12// maxLoginLinksPerHour bounds what one account's address can be made to 12// maxLoginLinksPerHour bounds what one account's address can be made to
13// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes 13// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
14// and retries, nothing for a script. The counter is shared with SSH-minted 14// and retries, nothing for a script. CountLoginTokensSince counts every row
15// links, not just these: CountLoginTokensSince counts every row in 15// in login_tokens, so links minted with "web login" over SSH and links
16// login_tokens, and "web login" over SSH inserts into that same table 16// mailed from the login page share the budget, and both refuse past it.
17// without consulting this bound, so five "ssh git@host web login" calls in
18// an hour also spend an account's budget here.
19const maxLoginLinksPerHour = 5 17const maxLoginLinksPerHour = 5
20 18
21// loginLinkTTL is longer than the five minutes an SSH-minted link gets. 19// loginLinkTTL is longer than the five minutes an SSH-minted link gets.
internal/control/web.go +8
@@ -86,6 +86,14 @@ func runWebLogin(c *Ctx, args []string) int {
86 return c.fail(protocol.ExitDenied, 86 return c.fail(protocol.ExitDenied,
87 "this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode) 87 "this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode)
88 } 88 }
89 n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour))
90 if err != nil {
91 return c.fail(protocol.ExitFailure, "%v", err)
92 }
93 if n >= maxLoginLinksPerHour {
94 return c.fail(protocol.ExitDenied,
95 "%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour)
96 }
89 token, hash, err := newStoredToken() 97 token, hash, err := newStoredToken()
90 if err != nil { 98 if err != nil {
91 return c.fail(protocol.ExitFailure, "%v", err) 99 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/weblogin_test.go added +28
@@ -0,0 +1,28 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store"
9)
10
11// web login over SSH counts against the same hourly bound as the
12// mailed links, since both insert into login_tokens (#278).
13func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) {
14 st, _, uid := newQueueTestRepo(t)
15 for i := 0; i <= maxLoginLinksPerHour; i++ {
16 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
17 c.Cfg.Web.Mode = "accounts"
18 c.Cfg.Server.SiteURL = "https://gitbay.test"
19 c.Cfg.Limits.WriteRate = -1
20 code := Dispatch(c, []string{"web", "login"})
21 switch {
22 case i < maxLoginLinksPerHour && code != protocol.ExitOK:
23 t.Fatalf("link %d: exit %d %s", i+1, code, errOut)
24 case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")):
25 t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut)
26 }
27 }
28}