web: idle timeout for browser sessions !487

merged merged by cmc on 2026-09-28 21:49 UTC · krz/gitbay:session-idle into main

11 files changed, +125 −18

Layout: unified · split

.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −1
@@ -18,7 +18,7 @@
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | optional =--ttl=, refused at auth | =keys remove= (own keys); closes its connections | 18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | optional =--ttl=, refused at auth | =keys remove= (own keys); closes its connections |
19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | optional =--ttl=, refused at auth | =repo deploy-key remove= (repo admin); closes its connections | 19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | optional =--ttl=, refused at auth | =repo deploy-key remove= (repo admin); closes its connections |
20| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= | 20| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= |
21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= | 21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 12 h idle, 7 days absolute | logout, =web sessions revoke= |
22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | 22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | 23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | 24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use |
@@ -27,6 +27,7 @@
27Generation and hashing: =internal/store/sessions.go= (=NewToken=, 27Generation and hashing: =internal/store/sessions.go= (=NewToken=,
28=HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=, 28=HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=,
29=SameSite=Lax=, =Secure= unless TLS is off, =MaxAge= 7 days 29=SameSite=Lax=, =Secure= unless TLS is off, =MaxAge= 7 days
30(the session itself also ends after 12 hours idle)
30(=internal/httpd/accounts.go=). Token scope values are 31(=internal/httpd/accounts.go=). Token scope values are
31constrained by a database =CHECK= as well as the command 32constrained by a database =CHECK= as well as the command
32(=internal/store/migrations/0004_api_tokens.up.sql=). 33(=internal/store/migrations/0004_api_tokens.up.sql=).
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -23,7 +23,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
23| Brute-force limit on SSH auth | in place | 10 failures a minute per IP (=internal/sshd/ratelimit.go=) | 23| Brute-force limit on SSH auth | in place | 10 failures a minute per IP (=internal/sshd/ratelimit.go=) |
24| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) | 24| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) |
25| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | 25| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
26| Session lifetime | partial | 7 days absolute, no idle timeout (#276) | 26| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
27| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | 27| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
28| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) | 28| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
29| Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) | 29| Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) |
.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| #276 | Sessions | Web sessions last 7 days with no idle timeout | low |
23| #278 | Login links | =web login= over SSH skips the login-link rate limit | low | 22| #278 | Login links | =web login= over SSH skips the login-link rate limit | low |
24| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium | 23| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
25| #280 | Mail | STARTTLS only when the relay offers it | medium | 24| #280 | Mail | STARTTLS only when the relay offers it | medium |
.gitbay/wiki/Users.org +4 −3
@@ -682,9 +682,10 @@ and deletes through the commands above.
682 682
683* Browser sessions 683* Browser sessions
684 684
685=gitbay web login= mints a one-time URL; the session it opens lasts 685=gitbay web login= mints a one-time URL; the session it opens ends
686seven days. =gitbay web sessions list= shows each of yours by a short 686after twelve hours without a request, and after seven days in any case.
687id with its creation and expiry, and =gitbay web sessions revoke <id>= 687=gitbay web sessions list= shows each of yours by a short id with its
688creation, expiry and last use, and =gitbay web sessions revoke <id>=
688or =--all= ends them from the terminal, which is where a lost laptop is 689or =--all= ends them from the terminal, which is where a lost laptop is
689handled. 690handled.
690 691
CHANGELOG.org +5 −1
@@ -6,7 +6,8 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9Credentials: revocation, delegation and expiry (#256, #257, #277). 9Credentials and sessions: revocation, delegation, expiry and an idle
10timeout (#256, #257, #276, #277).
10 11
11*Upgrade note.* =token create= makes a =read= token unless given 12*Upgrade note.* =token create= makes a =read= token unless given
12=--scope full=. A script that mints a token and then writes with it 13=--scope full=. A script that mints a token and then writes with it
@@ -27,6 +28,9 @@ must add =--scope full=. Existing tokens keep their scope.
27 =EXPIRES= columns, after the label (#277). 28 =EXPIRES= columns, after the label (#277).
28- =token list= at a terminal shows a future expiry as a time, not 29- =token list= at a terminal shows a future expiry as a time, not
29 "just now" (#286). 30 "just now" (#286).
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
33 hours. =web sessions list= shows when each was last used (#276).
30 34
31* v1.36.0 — 2026-09-23 35* v1.36.0 — 2026-09-23
32 36
internal/control/web.go +2 −2
@@ -40,7 +40,7 @@ func runWebSessionsList(c *Ctx, args []string) int {
40 return c.fail(protocol.ExitFailure, "%v", err) 40 return c.fail(protocol.ExitFailure, "%v", err)
41 } 41 }
42 return c.emit(sessions, func(w io.Writer) { 42 return c.emit(sessions, func(w io.Writer) {
43 tb := c.table(w, "ID", "SINCE", "UNTIL") 43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
44 for _, s := range sessions { 44 for _, s := range sessions {
45 since, until := s.CreatedAt, s.ExpiresAt 45 since, until := s.CreatedAt, s.ExpiresAt
46 if c.Term.Cols == 0 { 46 if c.Term.Cols == 0 {
@@ -48,7 +48,7 @@ func runWebSessionsList(c *Ctx, args []string) int {
48 } else { 48 } else {
49 since, until = relAge(since, termNow()), relAge(until, termNow()) 49 since, until = relAge(since, termNow()), relAge(until, termNow())
50 } 50 }
51 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until)) 51 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText(c.usedText(s.LastUsedAt)))
52 } 52 }
53 tb.flush() 53 tb.flush()
54 }) 54 })
internal/httpd/accounts.go +2
@@ -144,6 +144,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
144 http.Error(w, "internal error", http.StatusInternalServerError) 144 http.Error(w, "internal error", http.StatusInternalServerError)
145 return 145 return
146 } 146 }
147 // Seven days is the cap; the store ends it sooner after
148 // store.WebSessionIdle without a request.
147 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil { 149 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
148 http.Error(w, "internal error", http.StatusInternalServerError) 150 http.Error(w, "internal error", http.StatusInternalServerError)
149 return 151 return
internal/store/migrations/0062_web_session_idle.down.sql added +3
@@ -0,0 +1,3 @@
1UPDATE web_sessions SET expires_at = absolute_expires_at;
2ALTER TABLE web_sessions DROP COLUMN last_used_at;
3ALTER TABLE web_sessions DROP COLUMN absolute_expires_at;
internal/store/migrations/0062_web_session_idle.up.sql added +8
@@ -0,0 +1,8 @@
1-- expires_at slides forward on use, never past absolute_expires_at.
2-- Sessions open now keep their cap and get a full idle window from here.
3ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT;
4ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT;
5UPDATE web_sessions SET
6 absolute_expires_at = expires_at,
7 last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
8 expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours'));
internal/store/sessions.go +25 −9
@@ -64,25 +64,40 @@ func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
64 return userID, err 64 return userID, err
65} 65}
66 66
67// WebSessionIdle is how long a browser session lasts without a request.
68// Each use moves its expiry this far ahead, never past the cap it was
69// created with. Migration 0062 repeats the value for sessions it
70// converts.
71const WebSessionIdle = 12 * time.Hour
72
73// CreateWebSession stores a session that lapses after WebSessionIdle
74// without use, and after ttl regardless.
67func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error { 75func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
76 now := time.Now()
68 _, err := s.DB.Exec( 77 _, err := s.DB.Exec(
69 "INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", 78 "INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
70 hash, userID, fmtTime(time.Now().Add(ttl))) 79 hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
71 return err 80 return err
72} 81}
73 82
74// WebSessionUser resolves a session cookie hash to its user. 83// WebSessionUser resolves a session cookie hash to its user and renews
84// the session's idle expiry. A session is written at most once a
85// minute, so a burst of requests costs one UPDATE.
75func (s *Store) WebSessionUser(hash string) (User, error) { 86func (s *Store) WebSessionUser(hash string) (User, error) {
87 now := time.Now()
76 var userID int64 88 var userID int64
77 err := s.DB.QueryRow( 89 err := s.DB.QueryRow(
78 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", 90 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
79 hash, fmtTime(time.Now())).Scan(&userID) 91 hash, fmtTime(now)).Scan(&userID)
80 if errors.Is(err, sql.ErrNoRows) { 92 if errors.Is(err, sql.ErrNoRows) {
81 return User{}, ErrNotFound 93 return User{}, ErrNotFound
82 } 94 }
83 if err != nil { 95 if err != nil {
84 return User{}, err 96 return User{}, err
85 } 97 }
98 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
99 WHERE token_hash = ? AND last_used_at < ?`,
100 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
86 return s.UserByID(userID) 101 return s.UserByID(userID)
87} 102}
88 103
@@ -95,14 +110,15 @@ func (s *Store) DeleteWebSession(hash string) error {
95// twelve hex digits of the stored token hash: enough to name it, and a 110// twelve hex digits of the stored token hash: enough to name it, and a
96// hash of the cookie rather than the cookie. 111// hash of the cookie rather than the cookie.
97type WebSession struct { 112type WebSession struct {
98 ID string `json:"id"` 113 ID string `json:"id"`
99 CreatedAt string `json:"created_at"` 114 CreatedAt string `json:"created_at"`
100 ExpiresAt string `json:"expires_at"` 115 ExpiresAt string `json:"expires_at"`
116 LastUsedAt string `json:"last_used_at"`
101} 117}
102 118
103// ListWebSessions lists the user's unexpired browser sessions, newest first. 119// ListWebSessions lists the user's unexpired browser sessions, newest first.
104func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) { 120func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
105 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at 121 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
106 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`, 122 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
107 userID, fmtTime(time.Now())) 123 userID, fmtTime(time.Now()))
108 if err != nil { 124 if err != nil {
@@ -112,7 +128,7 @@ func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
112 var out []WebSession 128 var out []WebSession
113 for rows.Next() { 129 for rows.Next() {
114 var ws WebSession 130 var ws WebSession
115 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt); err != nil { 131 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
116 return nil, err 132 return nil, err
117 } 133 }
118 out = append(out, ws) 134 out = append(out, ws)
internal/store/sessions_test.go +73
@@ -1,6 +1,7 @@
1package store 1package store
2 2
3import ( 3import (
4 "database/sql"
4 "testing" 5 "testing"
5 "time" 6 "time"
6) 7)
@@ -44,3 +45,75 @@ func TestCountLoginTokensSince(t *testing.T) {
44 t.Fatalf("other account count = %d, %v; want 0", n, err) 45 t.Fatalf("other account count = %d, %v; want 0", n, err)
45 } 46 }
46} 47}
48
49func sessionFixture(t *testing.T) (*Store, int64) {
50 t.Helper()
51 s := open(t)
52 if err := s.MigrateUp(); err != nil {
53 t.Fatal(err)
54 }
55 uid, err := s.CreateUser("cmc", false)
56 if err != nil {
57 t.Fatal(err)
58 }
59 return s, uid
60}
61
62func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) {
63 t.Helper()
64 var e, a string
65 if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil {
66 t.Fatal(err)
67 }
68 return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true})
69}
70
71func TestWebSessionIdleExpiry(t *testing.T) {
72 s, uid := sessionFixture(t)
73 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
74 t.Fatal(err)
75 }
76 exp, abs := sessionTimes(t, s, "h")
77 if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle {
78 t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle)
79 }
80 if d := time.Until(abs); d < 7*24*time.Hour-time.Minute {
81 t.Fatalf("absolute cap in %s", d)
82 }
83 // Idle past the window: gone.
84 old := fmtTime(time.Now().Add(-time.Second))
85 s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old)
86 if _, err := s.WebSessionUser("h"); err != ErrNotFound {
87 t.Fatalf("idle session: %v", err)
88 }
89}
90
91func TestWebSessionRenewsUpToTheCap(t *testing.T) {
92 s, uid := sessionFixture(t)
93 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
94 t.Fatal(err)
95 }
96 // Last used two minutes ago, one minute left: a request renews it.
97 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'",
98 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute)))
99 if _, err := s.WebSessionUser("h"); err != nil {
100 t.Fatal(err)
101 }
102 if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute {
103 t.Fatalf("not renewed: expires in %s", time.Until(exp))
104 }
105 // Near the cap, renewal stops at it.
106 capAt := time.Now().Add(time.Hour)
107 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'",
108 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt))
109 if _, err := s.WebSessionUser("h"); err != nil {
110 t.Fatal(err)
111 }
112 if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) {
113 t.Fatalf("renewed past the cap: %s > %s", exp, capAt)
114 }
115 list, err := s.ListWebSessions(uid)
116 if err != nil || len(list) != 1 || list[0].LastUsedAt == "" {
117 t.Fatalf("list: %+v %v", list, err)
118 }
119}