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.
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| #278 | Login links | =web login= over SSH skips the login-link rate limit | low |
2322| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
2423| #280 | Mail | STARTTLS only when the relay offers it | medium |
2524| #281 | TLS | No explicit minimum TLS version | low |
CHANGELOG.org +2
@@ -31,6 +31,8 @@ must add =--scope full=. Existing tokens keep their scope.
3131- Browser sessions end after twelve hours without a request, and after
3232 seven days as before. Sessions open at upgrade get a fresh twelve
3333 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).
3436
3537* v1.36.0 — 2026-09-23
3638
internal/control/loginlink.go +3 −5
@@ -11,11 +11,9 @@ import (
1111
1212// maxLoginLinksPerHour bounds what one account's address can be made to
1313// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
14// and retries, nothing for a script. The counter is shared with SSH-minted
15// links, not just these: CountLoginTokensSince counts every row in
16// login_tokens, and "web login" over SSH inserts into that same table
17// without consulting this bound, so five "ssh git@host web login" calls in
18// an hour also spend an account's budget here.
14// and retries, nothing for a script. CountLoginTokensSince counts every row
15// in login_tokens, so links minted with "web login" over SSH and links
16// mailed from the login page share the budget, and both refuse past it.
1917const maxLoginLinksPerHour = 5
2018
2119// 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 {
8686 return c.fail(protocol.ExitDenied,
8787 "this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode)
8888 }
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 }
8997 token, hash, err := newStoredToken()
9098 if err != nil {
9199 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}