web: idle timeout for browser sessions !487
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 @@ | |||
| 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, | 27 | Generation 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 |
| 31 | constrained by a database =CHECK= as well as the command | 32 | constrained 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 |
| 686 | seven days. =gitbay web sessions list= shows each of yours by a short | 686 | after twelve hours without a request, and after seven days in any case. |
| 687 | id 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 |
| 688 | creation, expiry and last use, and =gitbay web sessions revoke <id>= | ||
| 688 | or =--all= ends them from the terminal, which is where a lost laptop is | 689 | or =--all= ends them from the terminal, which is where a lost laptop is |
| 689 | handled. | 690 | handled. |
| 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 | ||
| 9 | Credentials: revocation, delegation and expiry (#256, #257, #277). | 9 | Credentials and sessions: revocation, delegation, expiry and an idle |
| 10 | timeout (#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 @@ | |||
| 1 | UPDATE web_sessions SET expires_at = absolute_expires_at; | ||
| 2 | ALTER TABLE web_sessions DROP COLUMN last_used_at; | ||
| 3 | ALTER 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. | ||
| 3 | ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT; | ||
| 4 | ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT; | ||
| 5 | UPDATE 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. | ||
| 71 | const WebSessionIdle = 12 * time.Hour | ||
| 72 | |||
| 73 | // CreateWebSession stores a session that lapses after WebSessionIdle | ||
| 74 | // without use, and after ttl regardless. | ||
| 67 | func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error { | 75 | func (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. | ||
| 75 | func (s *Store) WebSessionUser(hash string) (User, error) { | 86 | func (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. |
| 97 | type WebSession struct { | 112 | type 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. |
| 104 | func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) { | 120 | func (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 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | |||
| 49 | func 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 | |||
| 62 | func 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 | |||
| 71 | func 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 | |||
| 91 | func 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 | } | ||