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 | 19 | | #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | |
| 20 | 20 | | #274 | Backups | The local backup archive is not encrypted | medium | |
| 21 | 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 | 22 | | #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium | |
| 24 | 23 | | #280 | Mail | STARTTLS only when the relay offers it | medium | |
| 25 | 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 | 31 | - Browser sessions end after twelve hours without a request, and after |
| 32 | 32 | seven days as before. Sessions open at upgrade get a fresh twelve |
| 33 | 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 | 37 | * v1.36.0 — 2026-09-23 |
| 36 | 38 | |
internal/control/loginlink.go
+3 −5
| @@ -11,11 +11,9 @@ import ( |
| 11 | 11 | |
| 12 | 12 | // maxLoginLinksPerHour bounds what one account's address can be made to |
| 13 | 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 |
| 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. |
| 19 | 17 | const maxLoginLinksPerHour = 5 |
| 20 | 18 | |
| 21 | 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 | 86 | return c.fail(protocol.ExitDenied, |
| 87 | 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 | 97 | token, hash, err := newStoredToken() |
| 90 | 98 | if err != nil { |
| 91 | 99 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/control/weblogin_test.go
added
+28
| @@ -0,0 +1,28 @@ |
| 1 | package control |
| 2 | |
| 3 | import ( |
| 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). |
| 13 | func 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 | } |