web: mints and grants need a sign-in from the last 15 minutes !513

merged merged by cmc on 2026-09-29 02:25 UTC · krz/gitbay:web-mint-reauth into main

40 files changed, +553 −85

Layout: unified · split

.gitbay/wiki/Architecture/05-Identity-and-Access.org +12 −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 | 12 h idle, 7 days absolute | logout, =web sessions revoke= | 21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account; credential-minting and access-granting commands only within 15 minutes of sign-in | 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 |
@@ -118,6 +118,17 @@ button and the displayed status (=internal/control/mr.go=):
118- Destructive web actions (key, email and PGP removal, release, snippet, 118- Destructive web actions (key, email and PGP removal, release, snippet,
119 team and label deletion, user disable and demote) require the target's 119 team and label deletion, user disable and demote) require the target's
120 name typed into the form (=internal/httpd/confirm.go=). 120 name typed into the form (=internal/httpd/confirm.go=).
121- Commands that create a credential (SSH, deploy and runner keys, API
122 tokens, email verification, login links, PGP keys, device tokens) or
123 grant access (repository and organization roles, teams, transfers,
124 admin promote and enable, webhooks, secrets, mirrors) are refused
125 from a browser session that signed in more than 15 minutes ago
126 (=control.ReauthWindow=, =Command.NeedsRecentSignIn=). The sign-in
127 time is =web_sessions.created_at=, which idle renewal does not move;
128 a request with no sign-in time is refused. SSH, API tokens and host
129 commands are unaffected. The refusal is audited; the form shows it
130 with a sign-in link, and the login returns to the page through the
131 server-set =gitbay_next= cookie (=internal/httpd/flash.go=).
121 132
122* Rate limits 133* Rate limits
123 134
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -26,7 +26,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
26| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | 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=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.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=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.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 | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token; a browser session runs credential-minting and access-granting commands only within 15 minutes of signing in, and the refusal is audited (=internal/control/control.go=) |
30 30
31** Access control (V4) 31** Access control (V4)
32 32
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -13,7 +13,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
13| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | 13| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high |
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
17| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium | 16| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium |
18 17
19* Not filed 18* Not filed
.gitbay/wiki/Threat-Model.org +3 −2
@@ -61,8 +61,9 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite
61 rights narrowed by its credential's scope, decided in one place, so a 61 rights narrowed by its credential's scope, decided in one place, so a
62 bearer token is worth exactly its scope and no more, and a token or 62 bearer token is worth exactly its scope and no more, and a token or
63 SSH key with an expiry cannot create a credential that outlives it. 63 SSH key with an expiry cannot create a credential that outlives it.
64 Browser sessions are not covered yet (#297). Git transport never runs 64 A browser session can create one, or grant access, only within 15
65 over the API. 65 minutes of signing in (=control.ReauthWindow=, #297). Git transport
66 never runs over the API.
66- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where 67- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
67 enabled, the read-only web UI — carry no credentials and expose only 68 enabled, the read-only web UI — carry no credentials and expose only
68 public data. HTTP push is refused via a pkt-line =ERR=, never a 401. 69 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
.gitbay/wiki/Users.org +6
@@ -701,6 +701,12 @@ creation, expiry and last use, and =gitbay web sessions revoke <id>=
701or =--all= ends them from the terminal, which is where a lost laptop is 701or =--all= ends them from the terminal, which is where a lost laptop is
702handled. 702handled.
703 703
704Actions that create a credential or grant access (adding a key, token
705or email, org and repository roles, transfers) ask you to sign in again
706when your web sign-in is older than 15 minutes. The form shows a "Sign
707in again" link and the login returns to the page. Idle renewal does not
708extend this window.
709
704=web theme set light= or =dark= fixes the web UI's colour scheme for 710=web theme set light= or =dark= fixes the web UI's colour scheme for
705your account; =system=, the default, follows the browser's own 711your account; =system=, the default, follows the browser's own
706preference. =web theme show= prints it. The account page has the same 712preference. =web theme show= prints it. The account page has the same
CHANGELOG.org +10
@@ -23,6 +23,16 @@ source and a =--from=/remote URL carrying a query or fragment. A
23mirror or import whose host is written numerically (=127.1=, 23mirror or import whose host is written numerically (=127.1=,
24=2130706433=, =0x7f.1=) rather than as a dotted address is refused 24=2130706433=, =0x7f.1=) rather than as a dotted address is refused
25too; rewrite it before upgrading. 25too; rewrite it before upgrading.
26- A web session older than 15 minutes cannot mint a credential or grant
27 access — keys, PGP keys, tokens, org membership, and the admin
28 promote/enable actions — and the form it tried shows a sign-in link
29 that returns there (#297).
30- A browser session creates credentials and grants access — keys, PGP
31 keys, tokens, verified addresses, org and repository roles, transfers,
32 webhooks, secrets, mirrors, and the admin promote/enable actions —
33 only within 15 minutes of signing in. An older session gets the form
34 back with a "Sign in again" link, and the login returns to it. SSH and
35 API tokens are unaffected (#297).
26- The builds page's status badge section gives an org-mode snippet 36- The builds page's status badge section gives an org-mode snippet
27 beside the Markdown one, for a README.org (#299). 37 beside the Markdown one, for a README.org (#299).
28- API tokens on the settings page: create with a scope and optional 38- API tokens on the settings page: create with a scope and optional
internal/control/admin.go +5 −4
@@ -31,10 +31,11 @@ func init() {
31 Examples: []string{"admin user show alice"}, 31 Examples: []string{"admin user show alice"},
32 ReadOnly: true, Run: runAdminUserShow}) 32 ReadOnly: true, Run: runAdminUserShow})
33 register(Command{Path: []string{"admin", "user", "promote"}, 33 register(Command{Path: []string{"admin", "user", "promote"},
34 Summary: "make an account an instance admin", 34 NeedsRecentSignIn: true,
35 Usage: "admin user promote <username>", 35 Summary: "make an account an instance admin",
36 Examples: []string{"admin user promote alice"}, 36 Usage: "admin user promote <username>",
37 Run: runAdminUserPromote}) 37 Examples: []string{"admin user promote alice"},
38 Run: runAdminUserPromote})
38 register(Command{Path: []string{"admin", "user", "demote"}, 39 register(Command{Path: []string{"admin", "user", "demote"},
39 Summary: "remove instance admin from an account (never the last one)", 40 Summary: "remove instance admin from an account (never the last one)",
40 Usage: "admin user demote <username>", 41 Usage: "admin user demote <username>",
internal/control/adminhost.go +10 −9
@@ -32,17 +32,18 @@ func init() {
32 }, 32 },
33 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, 33 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"},
34 ReadsStdin: true, 34 ReadsStdin: true,
35 MintsCredential: true, Run: runAdminUserCreate}) 35 MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate})
36 register(Command{Path: []string{"admin", "user", "disable"}, 36 register(Command{Path: []string{"admin", "user", "disable"},
37 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", 37 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
38 Usage: "admin user disable <username>", 38 Usage: "admin user disable <username>",
39 Examples: []string{"admin user disable alice"}, 39 Examples: []string{"admin user disable alice"},
40 Run: runAdminUserDisable}) 40 Run: runAdminUserDisable})
41 register(Command{Path: []string{"admin", "user", "enable"}, 41 register(Command{Path: []string{"admin", "user", "enable"},
42 Summary: "restore a suspended account", 42 NeedsRecentSignIn: true,
43 Usage: "admin user enable <username>", 43 Summary: "restore a suspended account",
44 Examples: []string{"admin user enable alice"}, 44 Usage: "admin user enable <username>",
45 Run: runAdminUserEnable}) 45 Examples: []string{"admin user enable alice"},
46 Run: runAdminUserEnable})
46 register(Command{Path: []string{"admin", "user", "delete"}, 47 register(Command{Path: []string{"admin", "user", "delete"},
47 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", 48 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
48 Usage: "admin user delete <username> --yes", 49 Usage: "admin user delete <username> --yes",
@@ -55,8 +56,8 @@ func init() {
55 Summary: "mark an address verified by admin assertion", 56 Summary: "mark an address verified by admin assertion",
56 Usage: "admin email verify <username> <address>", 57 Usage: "admin email verify <username> <address>",
57 Examples: []string{"admin email verify alice alice@example.org"}, 58 Examples: []string{"admin email verify alice alice@example.org"},
58 MintsCredential: true, 59 MintsCredential: true, NeedsRecentSignIn: true,
59 Run: runAdminEmailVerify}) 60 Run: runAdminEmailVerify})
60 register(Command{Path: []string{"admin", "invite"}, 61 register(Command{Path: []string{"admin", "invite"},
61 Summary: "issue a registration invite and mail its code", 62 Summary: "issue a registration invite and mail its code",
62 Usage: "admin invite --email <address>", 63 Usage: "admin invite --email <address>",
@@ -64,8 +65,8 @@ func init() {
64 {"--email", "<address>", "who the invite is for", ""}, 65 {"--email", "<address>", "who the invite is for", ""},
65 }, 66 },
66 Examples: []string{"admin invite --email alice@example.org"}, 67 Examples: []string{"admin invite --email alice@example.org"},
67 MintsCredential: true, 68 MintsCredential: true, NeedsRecentSignIn: true,
68 Run: runAdminInvite}) 69 Run: runAdminInvite})
69 register(Command{Path: []string{"admin", "stats"}, 70 register(Command{Path: []string{"admin", "stats"},
70 Summary: "instance statistics: counts and per-repository disk usage", 71 Summary: "instance statistics: counts and per-repository disk usage",
71 Usage: "admin stats", 72 Usage: "admin stats",
internal/control/build.go +5 −4
@@ -69,10 +69,11 @@ func init() {
69 // repo's builds as environment variables. Same discipline as mirror 69 // repo's builds as environment variables. Same discipline as mirror
70 // tokens — the value never appears in argv, logs, or output. 70 // tokens — the value never appears in argv, logs, or output.
71 register(Command{Path: []string{"repo", "secret", "set"}, 71 register(Command{Path: []string{"repo", "secret", "set"},
72 Summary: "set a build secret", 72 NeedsRecentSignIn: true,
73 Usage: "repo secret set <owner/name> <NAME> (value on stdin)", 73 Summary: "set a build secret",
74 Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"}, 74 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
75 ReadsStdin: true, Run: runSecretSet}) 75 Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"},
76 ReadsStdin: true, Run: runSecretSet})
76 register(Command{Path: []string{"repo", "secret", "remove"}, 77 register(Command{Path: []string{"repo", "secret", "remove"},
77 Summary: "remove a build secret", 78 Summary: "remove a build secret",
78 Usage: "repo secret remove <owner/name> <NAME>", 79 Usage: "repo secret remove <owner/name> <NAME>",
internal/control/control.go +30 −1
@@ -69,6 +69,27 @@ type Ctx struct {
69 Stopping <-chan struct{} 69 Stopping <-chan struct{}
70} 70}
71 71
72// SourceWeb is Ctx.Source for a request from a browser session. Its
73// User.SignedInAt is when that session signed in.
74const SourceWeb = "web"
75
76// ReauthWindow is how long after signing in a browser session may run a
77// NeedsRecentSignIn command. A session lasts days and its cookie is a
78// bearer credential; what it creates or grants must come from a recent
79// sign-in (#297).
80const ReauthWindow = 15 * time.Minute
81
82// ReauthRefusal is what a web session signed in longer ago than
83// ReauthWindow gets; the web shows a sign-in link beside it.
84var ReauthRefusal = fmt.Sprintf("this action from the web needs a sign-in from the last %d minutes; sign in again, then submit the form again",
85 int(ReauthWindow/time.Minute))
86
87// staleSignIn reports whether a web session that signed in at at is too
88// old, at now, to run a NeedsRecentSignIn command. A zero at is stale.
89func staleSignIn(at, now time.Time) bool {
90 return now.Sub(at) > ReauthWindow
91}
92
72// usage reports a bad invocation with the command's registered usage, 93// usage reports a bad invocation with the command's registered usage,
73// the one source of it. 94// the one source of it.
74func (c *Ctx) usage() int { 95func (c *Ctx) usage() int {
@@ -104,7 +125,12 @@ type Command struct {
104 // to obtain one: tokens, keys, login links, invites, accounts, 125 // to obtain one: tokens, keys, login links, invites, accounts,
105 // verified addresses. An expiring credential may not run it. 126 // verified addresses. An expiring credential may not run it.
106 MintsCredential bool 127 MintsCredential bool
107 Run func(c *Ctx, args []string) int 128 // NeedsRecentSignIn marks a command a browser session may run only
129 // within ReauthWindow of signing in: every MintsCredential command,
130 // and those that give an account lasting access or open a standing
131 // channel out of the instance.
132 NeedsRecentSignIn bool
133 Run func(c *Ctx, args []string) int
108} 134}
109 135
110var registry []Command 136var registry []Command
@@ -212,6 +238,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int {
212 if c.User.Disabled { 238 if c.User.Disabled {
213 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") 239 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
214 } 240 }
241 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
242 return c.fail(protocol.ExitDenied, "%s", ReauthRefusal)
243 }
215 // The admin noun is gated here as well as in each handler, so a new 244 // The admin noun is gated here as well as in each handler, so a new
216 // admin command that forgets requireInstanceAdmin is still refused. 245 // admin command that forgets requireInstanceAdmin is still refused.
217 if cmd.Path[0] == "admin" && !c.User.IsAdmin { 246 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
internal/control/deploykey.go +1 −1
@@ -23,7 +23,7 @@ func init() {
23 }, 23 },
24 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, 24 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
25 ReadsStdin: true, 25 ReadsStdin: true,
26 MintsCredential: true, Run: runDeployKeyAdd}) 26 MintsCredential: true, NeedsRecentSignIn: true, Run: runDeployKeyAdd})
27 register(Command{Path: []string{"repo", "deploy-key", "list"}, 27 register(Command{Path: []string{"repo", "deploy-key", "list"},
28 Summary: "list deploy keys", 28 Summary: "list deploy keys",
29 Usage: "repo deploy-key list <owner/name>", 29 Usage: "repo deploy-key list <owner/name>",
internal/control/identity.go +2 −2
@@ -42,8 +42,8 @@ func init() {
42 }, 42 },
43 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, 43 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
44 ReadsStdin: true, 44 ReadsStdin: true,
45 MintsCredential: true, 45 MintsCredential: true, NeedsRecentSignIn: true,
46 Run: runKeysAdd, 46 Run: runKeysAdd,
47 }) 47 })
48 register(Command{ 48 register(Command{
49 Path: []string{"keys", "label"}, 49 Path: []string{"keys", "label"},
internal/control/mirrorcmd.go +3 −2
@@ -16,8 +16,9 @@ import (
16 16
17func init() { 17func init() {
18 register(Command{Path: []string{"repo", "mirror", "add"}, 18 register(Command{Path: []string{"repo", "mirror", "add"},
19 Summary: "mirror to or from a remote", 19 NeedsRecentSignIn: true,
20 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", 20 Summary: "mirror to or from a remote",
21 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]",
21 Flags: []Flag{ 22 Flags: []Flag{
22 {"--direction", "push|pull", "which way the mirror syncs", ""}, 23 {"--direction", "push|pull", "which way the mirror syncs", ""},
23 {"--username", "<u>", "the remote's username", ""}, 24 {"--username", "<u>", "the remote's username", ""},
internal/control/notifications.go +3 −2
@@ -48,8 +48,9 @@ func init() {
48 Examples: []string{"notifications settings watch on"}, 48 Examples: []string{"notifications settings watch on"},
49 Run: runNotificationsSettingsWatch}) 49 Run: runNotificationsSettingsWatch})
50 register(Command{Path: []string{"notifications", "device", "add"}, 50 register(Command{Path: []string{"notifications", "device", "add"},
51 Summary: "register an Apple device for push, token on stdin", 51 NeedsRecentSignIn: true,
52 Usage: "notifications device add [--label <name>] < token", 52 Summary: "register an Apple device for push, token on stdin",
53 Usage: "notifications device add [--label <name>] < token",
53 Flags: []Flag{ 54 Flags: []Flag{
54 {"--label", "<name>", "a name for the device", ""}, 55 {"--label", "<name>", "a name for the device", ""},
55 }, 56 },
internal/control/org.go +3 −2
@@ -37,8 +37,9 @@ func init() {
37 }, 37 },
38 Examples: []string{"org delete krz --yes"}, Run: runOrgDelete}) 38 Examples: []string{"org delete krz --yes"}, Run: runOrgDelete})
39 register(Command{Path: []string{"org", "members", "add"}, 39 register(Command{Path: []string{"org", "members", "add"},
40 Summary: "add or update a member", 40 NeedsRecentSignIn: true,
41 Usage: "org members add <org> <user> [--role member|admin]", 41 Summary: "add or update a member",
42 Usage: "org members add <org> <user> [--role member|admin]",
42 Flags: []Flag{ 43 Flags: []Flag{
43 {"--role", "member|admin", "the member's role", "member"}, 44 {"--role", "member|admin", "the member's role", "member"},
44 }, 45 },
internal/control/reauth_test.go added +136
@@ -0,0 +1,136 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestStaleSignInBoundary(t *testing.T) {
14 at := time.Now()
15 if staleSignIn(at, at.Add(ReauthWindow)) {
16 t.Error("exactly ReauthWindow counted as stale")
17 }
18 if !staleSignIn(at, at.Add(ReauthWindow+time.Second)) {
19 t.Error("ReauthWindow plus a second counted as fresh")
20 }
21 if !staleSignIn(time.Time{}, at) {
22 t.Error("a zero sign-in time counted as fresh")
23 }
24}
25
26// A browser session runs NeedsRecentSignIn commands only within
27// ReauthWindow of signing in; SSH, the API and the host carry no
28// session and are not affected (#297).
29func TestRecentSignInGate(t *testing.T) {
30 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
31 st, repo, uid := newQueueTestRepo(t)
32 if _, err := st.CreateUser("bob", false); err != nil {
33 t.Fatal(err)
34 }
35 run := func(source string, signedIn time.Time, stdin string, argv ...string) (string, int) {
36 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice", SignedInAt: signedIn})
37 c.Cfg.Limits.WriteRate = -1
38 c.Source = source
39 c.ViaAPI = source == SourceWeb || source == "api"
40 c.Stdin = strings.NewReader(stdin)
41 code := Dispatch(c, argv)
42 return strings.TrimSpace(errOut.String()), code
43 }
44 fresh := time.Now().Add(-time.Minute)
45 stale := time.Now().Add(-ReauthWindow - time.Minute)
46 staleKey := authorizedKey(t, "stale")
47
48 for _, tc := range []struct {
49 name string
50 signedIn time.Time
51 stdin string
52 argv []string
53 }{
54 {"stale keys add", stale, staleKey, []string{"keys", "add"}},
55 {"stale token create", stale, "", []string{"token", "create", "--name", "x"}},
56 {"stale repo access grant", stale, "", []string{"repo", "access", "grant", repo.Path(), "bob", "write"}},
57 {"zero sign-in time", time.Time{}, authorizedKey(t, "zero"), []string{"keys", "add"}},
58 } {
59 if msg, code := run(SourceWeb, tc.signedIn, tc.stdin, tc.argv...); code != protocol.ExitDenied || msg != ReauthRefusal {
60 t.Errorf("%s: exit %d, %q", tc.name, code, msg)
61 }
62 }
63 if msg, code := run(SourceWeb, fresh, authorizedKey(t, "fresh"), "keys", "add"); code != protocol.ExitOK {
64 t.Fatalf("fresh session: exit %d, %q", code, msg)
65 }
66 // SSH, the API and the host have no session; a zero SignedInAt is
67 // what they carry.
68 for _, source := range []string{"SHA256:abc", "api", "host"} {
69 if msg, code := run(source, time.Time{}, authorizedKey(t, source), "keys", "add"); code != protocol.ExitOK {
70 t.Fatalf("%s: exit %d, %q", source, code, msg)
71 }
72 }
73 // A command that grants nothing is not held back.
74 if msg, code := run(SourceWeb, stale, "", "keys", "list"); code != protocol.ExitOK {
75 t.Fatalf("keys list on a stale session: exit %d, %q", code, msg)
76 }
77 keys, err := st.ListSSHKeys(uid)
78 if err != nil || len(keys) != 4 {
79 t.Fatalf("keys: %d %v, want the fresh, ssh, api and host ones", len(keys), err)
80 }
81 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
82 if err != nil {
83 t.Fatal(err)
84 }
85 if len(got) != 4 {
86 t.Fatalf("refusal audit rows: %+v", got)
87 }
88 keyText := strings.Fields(staleKey)[1]
89 for _, e := range got {
90 if strings.Contains(e.Data, keyText) {
91 t.Errorf("%s kept the key: %s", e.Action, e.Data)
92 }
93 }
94}
95
96// The set of commands a stale web session is refused. Adding one is a
97// decision; it shows up here.
98func TestNeedsRecentSignInSet(t *testing.T) {
99 var got []string
100 for _, cmd := range Commands() {
101 if cmd.MintsCredential && !cmd.NeedsRecentSignIn {
102 t.Errorf("%s mints a credential without NeedsRecentSignIn", joinPath(cmd.Path))
103 }
104 if cmd.NeedsRecentSignIn {
105 got = append(got, joinPath(cmd.Path))
106 }
107 }
108 slices.Sort(got)
109 want := []string{
110 "admin email verify",
111 "admin invite",
112 "admin user create",
113 "admin user enable",
114 "admin user promote",
115 "email verify",
116 "keys add",
117 "notifications device add",
118 "org members add",
119 "org settings members-role",
120 "org team add",
121 "org team grant",
122 "pgp add",
123 "repo access grant",
124 "repo deploy-key add",
125 "repo mirror add",
126 "repo runner add",
127 "repo secret set",
128 "repo transfer",
129 "token create",
130 "web login",
131 "webhook add",
132 }
133 if !slices.Equal(got, want) {
134 t.Fatalf("NeedsRecentSignIn commands:\n got %q\nwant %q", got, want)
135 }
136}
internal/control/register.go +2 −2
@@ -38,8 +38,8 @@ func init() {
38 register(Command{Path: []string{"email", "verify"}, 38 register(Command{Path: []string{"email", "verify"},
39 Summary: "confirm a verification code", 39 Summary: "confirm a verification code",
40 Usage: "email verify <code>", 40 Usage: "email verify <code>",
41 MintsCredential: true, 41 MintsCredential: true, NeedsRecentSignIn: true,
42 Examples: []string{"email verify abc123"}, Run: runEmailVerify}) 42 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
43 register(Command{Path: []string{"email", "list"}, 43 register(Command{Path: []string{"email", "list"},
44 Summary: "list the addresses on your account", 44 Summary: "list the addresses on your account",
45 Usage: "email list", 45 Usage: "email list",
internal/control/repo.go +10 −8
@@ -50,10 +50,11 @@ func init() {
50 Examples: []string{"repo show krz/gitbay"}, 50 Examples: []string{"repo show krz/gitbay"},
51 ReadOnly: true, Run: runRepoShow}) 51 ReadOnly: true, Run: runRepoShow})
52 register(Command{Path: []string{"repo", "transfer"}, 52 register(Command{Path: []string{"repo", "transfer"},
53 Summary: "move a repository to another owner", 53 NeedsRecentSignIn: true,
54 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", 54 Summary: "move a repository to another owner",
55 Examples: []string{"repo transfer krz/gitbay krazywarez"}, 55 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)",
56 Run: runRepoTransfer}) 56 Examples: []string{"repo transfer krz/gitbay krazywarez"},
57 Run: runRepoTransfer})
57 register(Command{Path: []string{"repo", "rename"}, 58 register(Command{Path: []string{"repo", "rename"},
58 Summary: "rename a repository", 59 Summary: "rename a repository",
59 Usage: "repo rename <owner/name> <new-name> (clone URLs change)", 60 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
@@ -68,10 +69,11 @@ func init() {
68 Examples: []string{"repo delete cmc/scratch --yes"}, 69 Examples: []string{"repo delete cmc/scratch --yes"},
69 Run: runRepoDelete}) 70 Run: runRepoDelete})
70 register(Command{Path: []string{"repo", "access", "grant"}, 71 register(Command{Path: []string{"repo", "access", "grant"},
71 Summary: "grant access", 72 NeedsRecentSignIn: true,
72 Usage: "repo access grant <owner/name> <user> read|write|admin", 73 Summary: "grant access",
73 Examples: []string{"repo access grant krz/gitbay cmc write"}, 74 Usage: "repo access grant <owner/name> <user> read|write|admin",
74 Run: runAccessGrant}) 75 Examples: []string{"repo access grant krz/gitbay cmc write"},
76 Run: runAccessGrant})
75 register(Command{Path: []string{"repo", "access", "revoke"}, 77 register(Command{Path: []string{"repo", "access", "revoke"},
76 Summary: "revoke access", 78 Summary: "revoke access",
77 Usage: "repo access revoke <owner/name> <user>", 79 Usage: "repo access revoke <owner/name> <user>",
internal/control/runnerrepo.go +1 −1
@@ -23,7 +23,7 @@ func init() {
23 Usage: "repo runner add <owner/name> < key.pub", 23 Usage: "repo runner add <owner/name> < key.pub",
24 Examples: []string{"repo runner add krz/gitbay < key.pub"}, 24 Examples: []string{"repo runner add krz/gitbay < key.pub"},
25 ReadsStdin: true, 25 ReadsStdin: true,
26 MintsCredential: true, Run: runRepoRunnerAdd}) 26 MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd})
27 register(Command{Path: []string{"repo", "runner", "list"}, 27 register(Command{Path: []string{"repo", "runner", "list"},
28 Summary: "list the runners attached to a repository", 28 Summary: "list the runners attached to a repository",
29 Usage: "repo runner list <owner/name>", 29 Usage: "repo runner list <owner/name>",
internal/control/sig.go +5 −4
@@ -18,10 +18,11 @@ import (
18 18
19func init() { 19func init() {
20 register(Command{Path: []string{"pgp", "add"}, 20 register(Command{Path: []string{"pgp", "add"},
21 Summary: "register an OpenPGP public key (armored)", 21 NeedsRecentSignIn: true,
22 Usage: "pgp add < key.asc", 22 Summary: "register an OpenPGP public key (armored)",
23 Examples: []string{"pgp add < key.asc"}, 23 Usage: "pgp add < key.asc",
24 ReadsStdin: true, Run: runPGPAdd}) 24 Examples: []string{"pgp add < key.asc"},
25 ReadsStdin: true, Run: runPGPAdd})
25 register(Command{Path: []string{"pgp", "list"}, 26 register(Command{Path: []string{"pgp", "list"},
26 Summary: "list registered OpenPGP keys", 27 Summary: "list registered OpenPGP keys",
27 Usage: "pgp list", 28 Usage: "pgp list",
internal/control/teams.go +12 −9
@@ -30,25 +30,28 @@ func init() {
30 Usage: "org team show <org> <team>", 30 Usage: "org team show <org> <team>",
31 Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow}) 31 Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow})
32 register(Command{Path: []string{"org", "team", "add"}, 32 register(Command{Path: []string{"org", "team", "add"},
33 Summary: "add org members to a team", 33 NeedsRecentSignIn: true,
34 Usage: "org team add <org> <team> <user>...", 34 Summary: "add org members to a team",
35 Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd}) 35 Usage: "org team add <org> <team> <user>...",
36 Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd})
36 register(Command{Path: []string{"org", "team", "remove"}, 37 register(Command{Path: []string{"org", "team", "remove"},
37 Summary: "remove members from a team", 38 Summary: "remove members from a team",
38 Usage: "org team remove <org> <team> <user>...", 39 Usage: "org team remove <org> <team> <user>...",
39 Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove}) 40 Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove})
40 register(Command{Path: []string{"org", "team", "grant"}, 41 register(Command{Path: []string{"org", "team", "grant"},
41 Summary: "grant a team a role on an org repo", 42 NeedsRecentSignIn: true,
42 Usage: "org team grant <org> <team> <owner/name> read|write|admin", 43 Summary: "grant a team a role on an org repo",
43 Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant}) 44 Usage: "org team grant <org> <team> <owner/name> read|write|admin",
45 Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant})
44 register(Command{Path: []string{"org", "team", "revoke"}, 46 register(Command{Path: []string{"org", "team", "revoke"},
45 Summary: "revoke a team's grant", 47 Summary: "revoke a team's grant",
46 Usage: "org team revoke <org> <team> <owner/name>", 48 Usage: "org team revoke <org> <team> <owner/name>",
47 Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke}) 49 Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke})
48 register(Command{Path: []string{"org", "settings", "members-role"}, 50 register(Command{Path: []string{"org", "settings", "members-role"},
49 Summary: "role plain membership implies on every org repo", 51 NeedsRecentSignIn: true,
50 Usage: "org settings members-role <org> write|read|none (default write)", 52 Summary: "role plain membership implies on every org repo",
51 Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole}) 53 Usage: "org settings members-role <org> write|read|none (default write)",
54 Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole})
52} 55}
53 56
54// orgAdminRef resolves an org and requires the caller to admin it. 57// orgAdminRef resolves an org and requires the caller to admin it.
internal/control/token.go +2 −2
@@ -22,8 +22,8 @@ func init() {
22 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"}, 22 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
23 }, 23 },
24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"}, 24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
25 MintsCredential: true, 25 MintsCredential: true, NeedsRecentSignIn: true,
26 Run: runTokenCreate}) 26 Run: runTokenCreate})
27 register(Command{Path: []string{"token", "list"}, 27 register(Command{Path: []string{"token", "list"},
28 Summary: "list API tokens", 28 Summary: "list API tokens",
29 Usage: "token list", 29 Usage: "token list",
internal/control/web.go +2 −2
@@ -16,8 +16,8 @@ func init() {
16 register(Command{Path: []string{"web", "login"}, 16 register(Command{Path: []string{"web", "login"},
17 Summary: "mint a one-time browser login URL", 17 Summary: "mint a one-time browser login URL",
18 Usage: "web login", 18 Usage: "web login",
19 MintsCredential: true, 19 MintsCredential: true, NeedsRecentSignIn: true,
20 Examples: []string{"web login"}, Run: runWebLogin}) 20 Examples: []string{"web login"}, Run: runWebLogin})
21 register(Command{Path: []string{"web", "sessions", "list"}, 21 register(Command{Path: []string{"web", "sessions", "list"},
22 Summary: "list your browser sessions", 22 Summary: "list your browser sessions",
23 Usage: "web sessions list", 23 Usage: "web sessions list",
internal/control/webhook.go +3 −2
@@ -15,8 +15,9 @@ import (
15 15
16func init() { 16func init() {
17 register(Command{Path: []string{"webhook", "add"}, 17 register(Command{Path: []string{"webhook", "add"},
18 Summary: "add a webhook", 18 NeedsRecentSignIn: true,
19 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]", 19 Summary: "add a webhook",
20 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
20 Flags: []Flag{ 21 Flags: []Flag{
21 {"--secret", "-", "read the secret that signs deliveries from stdin", ""}, 22 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
22 {"--events", "push,issue.created|*", "which events to send", "*"}, 23 {"--events", "push,issue.created|*", "which events to send", "*"},
internal/httpd/account.go +6 −2
@@ -128,6 +128,9 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath 128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
129 } 129 }
130 130
131 notice := s.takeFlash(w, r)
132 reauth := s.reauthNotice(w, notice, "/settings")
133
131 s.render(w, "account.html", struct { 134 s.render(w, "account.html", struct {
132 basePage 135 basePage
133 Tab string // marks the rail's Settings row as current 136 Tab string // marks the rail's Settings row as current
@@ -148,10 +151,11 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
148 ThemeSetting string // system, light or dark: the form's selected option 151 ThemeSetting string // system, light or dark: the form's selected option
149 Tokens []accountToken 152 Tokens []accountToken
150 TokenShown string // a token minted by this request, shown once 153 TokenShown string // a token minted by this request, shown once
154 Reauth bool // Notice is the stale-session refusal: link to sign in
151 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), 155 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
152 aboutRepo, aboutEdit, s.cfg.SiteHost(), 156 aboutRepo, aboutEdit, s.cfg.SiteHost(),
153 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, 157 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
154 tokens, tokenShown}) 158 tokens, tokenShown, reauth})
155} 159}
156 160
157// accountExport hands the browser the same bundle `account export` 161// accountExport hands the browser the same bundle `account export`
internal/httpd/account_test.go +2 −1
@@ -7,6 +7,7 @@ import (
7 "strconv" 7 "strconv"
8 "strings" 8 "strings"
9 "testing" 9 "testing"
10 "time"
10 11
11 "gitbay.org/gitbay/internal/config" 12 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/store" 13 "gitbay.org/gitbay/internal/store"
@@ -300,7 +301,7 @@ func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
300 if err != nil { 301 if err != nil {
301 t.Fatal(err) 302 t.Fatal(err)
302 } 303 }
303 return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice"} 304 return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice", SignedInAt: time.Now()}
304} 305}
305 306
306// The settings page lists a user's API tokens with scope and expiry, 307// The settings page lists a user's API tokens with scope and expiry,
internal/httpd/adminusers.go +3 −1
@@ -62,6 +62,7 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
62 } 62 }
63 next = "?" + q.Encode() 63 next = "?" + q.Encode()
64 } 64 }
65 notice := s.takeFlash(w, r)
65 s.render(w, "adminusers.html", struct { 66 s.render(w, "adminusers.html", struct {
66 basePage 67 basePage
67 Tab string 68 Tab string
@@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
69 Users []adminUserRow 70 Users []adminUserRow
70 Next string 71 Next string
71 Notice string 72 Notice string
72 }{s.baseFor(viewer), "admin", state, page.Items, next, s.takeFlash(w, r)}) 73 Reauth bool // Notice is the stale-session refusal: link to sign in
74 }{s.baseFor(viewer), "admin", state, page.Items, next, notice, s.reauthNotice(w, notice, r.URL.Path)})
73} 75}
74 76
75// adminUsersSubmit runs one account action. Each is the command an 77// adminUsersSubmit runs one account action. Each is the command an
internal/httpd/control.go +5 −5
@@ -34,7 +34,7 @@ func (s *Server) runControlCode(u store.User, argv []string) (out string, msg st
34 var stdout, stderr bytes.Buffer 34 var stdout, stderr bytes.Buffer
35 ctx := &control.Ctx{ 35 ctx := &control.Ctx{
36 User: u, 36 User: u,
37 Source: "web", 37 Source: control.SourceWeb,
38 Scope: "full", 38 Scope: "full",
39 Store: s.st, 39 Store: s.st,
40 Cfg: s.cfg, 40 Cfg: s.cfg,
@@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do
58 var stderr bytes.Buffer 58 var stderr bytes.Buffer
59 ctx := &control.Ctx{ 59 ctx := &control.Ctx{
60 User: u, 60 User: u,
61 Source: "web", 61 Source: control.SourceWeb,
62 Scope: "full", 62 Scope: "full",
63 Store: s.st, 63 Store: s.st,
64 Cfg: s.cfg, 64 Cfg: s.cfg,
@@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
104 var stdout, stderr bytes.Buffer 104 var stdout, stderr bytes.Buffer
105 ctx := &control.Ctx{ 105 ctx := &control.Ctx{
106 User: u, 106 User: u,
107 Source: "web", 107 Source: control.SourceWeb,
108 Scope: "full", 108 Scope: "full",
109 Store: s.st, 109 Store: s.st,
110 Cfg: s.cfg, 110 Cfg: s.cfg,
@@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
146 var stdout, stderr bytes.Buffer 146 var stdout, stderr bytes.Buffer
147 ctx := &control.Ctx{ 147 ctx := &control.Ctx{
148 User: u, 148 User: u,
149 Source: "web", 149 Source: control.SourceWeb,
150 Scope: "full", 150 Scope: "full",
151 Store: s.st, 151 Store: s.st,
152 Cfg: s.cfg, 152 Cfg: s.cfg,
@@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
187 var stdout, stderr bytes.Buffer 187 var stdout, stderr bytes.Buffer
188 ctx := &control.Ctx{ 188 ctx := &control.Ctx{
189 User: u, 189 User: u,
190 Source: "web", 190 Source: control.SourceWeb,
191 Scope: "full", 191 Scope: "full",
192 Store: s.st, 192 Store: s.st,
193 Cfg: s.cfg, 193 Cfg: s.cfg,
internal/httpd/flash.go +23 −3
@@ -4,6 +4,8 @@ import (
4 "net/http" 4 "net/http"
5 "net/url" 5 "net/url"
6 "strings" 6 "strings"
7
8 "gitbay.org/gitbay/internal/control"
7) 9)
8 10
9// A form action that fails redirects back to the page it came from with 11// A form action that fails redirects back to the page it came from with
@@ -44,13 +46,31 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string {
44 return msg 46 return msg
45} 47}
46 48
49// reauthNotice reports whether notice is Dispatch's refusal for a session
50// that signed in too long ago to mint a credential or grant access and,
51// when it is, remembers path so the sign-in the page links to returns
52// there (#297).
53func (s *Server) reauthNotice(w http.ResponseWriter, notice, path string) bool {
54 if notice != control.ReauthRefusal {
55 return false
56 }
57 s.setNext(w, path)
58 return true
59}
60
47const nextCookie = "gitbay_next" 61const nextCookie = "gitbay_next"
48 62
63// localPath reports whether p is a path on this host. Browsers read a
64// leading `/\` like "//", so it is refused too.
65func localPath(p string) bool {
66 return strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "//") && !strings.HasPrefix(p, "/\\")
67}
68
49// setNext remembers the local path an anonymous visitor asked for, so 69// setNext remembers the local path an anonymous visitor asked for, so
50// the login that follows can return there. Only a GET path is stored: 70// the login that follows can return there. Only a GET path is stored:
51// a POST must not be replayed. 71// a POST must not be replayed.
52func (s *Server) setNext(w http.ResponseWriter, path string) { 72func (s *Server) setNext(w http.ResponseWriter, path string) {
53 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > 300 { 73 if !localPath(path) || len(path) > 300 {
54 return 74 return
55 } 75 }
56 http.SetCookie(w, &http.Cookie{ 76 http.SetCookie(w, &http.Cookie{
@@ -69,7 +89,7 @@ func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string {
69 } 89 }
70 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode)) 90 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode))
71 p, err := url.QueryUnescape(c.Value) 91 p, err := url.QueryUnescape(c.Value)
72 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { 92 if err != nil || !localPath(p) {
73 return "" 93 return ""
74 } 94 }
75 return p 95 return p
@@ -83,7 +103,7 @@ func (s *Server) peekNext(r *http.Request) string {
83 return "" 103 return ""
84 } 104 }
85 p, err := url.QueryUnescape(c.Value) 105 p, err := url.QueryUnescape(c.Value)
86 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { 106 if err != nil || !localPath(p) {
87 return "" 107 return ""
88 } 108 }
89 return p 109 return p
internal/httpd/flash_test.go added +19
@@ -0,0 +1,19 @@
1package httpd
2
3import "testing"
4
5func TestLocalPath(t *testing.T) {
6 for p, want := range map[string]bool{
7 "/settings": true,
8 "/a/b?c=d": true,
9 "": false,
10 "settings": false,
11 "//evil.example": false,
12 `/\evil.example`: false,
13 "https://x.test/": false,
14 } {
15 if got := localPath(p); got != want {
16 t.Errorf("localPath(%q) = %v, want %v", p, got, want)
17 }
18 }
19}
internal/httpd/reauth_test.go added +159
@@ -0,0 +1,159 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// A session signed in longer ago than ReauthWindow cannot mint from the
17// settings page: the form comes back with the refusal and a sign-in
18// link, and the sign-in returns to /settings (#297).
19func TestWebMintNeedsRecentSignIn(t *testing.T) {
20 s, st, u := newTokenTestServer(t)
21 stale := u
22 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
23 rr := submitAccountForm(t, s, stale, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
24 if rr.Code != http.StatusSeeOther {
25 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
26 }
27 if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 0 {
28 t.Fatalf("a stale session minted %+v (%v)", list, err)
29 }
30
31 req := httptest.NewRequest("GET", "/settings", nil)
32 for _, c := range rr.Result().Cookies() {
33 req.AddCookie(c)
34 }
35 page := httptest.NewRecorder()
36 s.accountPage(page, req, stale)
37 body := page.Body.String()
38 if !strings.Contains(body, control.ReauthRefusal) {
39 t.Fatalf("refusal not shown: %s", body)
40 }
41 if !strings.Contains(body, `<a href="/login">Sign in again</a>`) {
42 t.Fatalf("no sign-in link: %s", body)
43 }
44 var next string
45 for _, c := range page.Result().Cookies() {
46 if c.Name == nextCookie {
47 next = c.Value
48 }
49 }
50 if next != url.QueryEscape("/settings") {
51 t.Fatalf("gitbay_next = %q, want /settings", next)
52 }
53}
54
55// An API token has no browser session: minting through the API is not
56// held to the sign-in window.
57func TestAPIMintIgnoresTheSignInWindow(t *testing.T) {
58 s, st, u := newTokenTestServer(t)
59 if err := st.CreateAPIToken(u.ID, "ci", store.HashToken("gb_reauthtest"), "full", nil, 0); err != nil {
60 t.Fatal(err)
61 }
62 req := httptest.NewRequest("POST", "/api/v1/cmd",
63 strings.NewReader(`{"argv":["token","create","--name","second","--scope","read"]}`))
64 req.Header.Set("Authorization", "Bearer gb_reauthtest")
65 rr := httptest.NewRecorder()
66 s.apiCmd(rr, req)
67 if rr.Code != http.StatusOK {
68 t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
69 }
70}
71
72// A fresh session mints without any refusal.
73func TestWebMintFreshSessionSucceeds(t *testing.T) {
74 s, st, u := newTokenTestServer(t)
75 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
76 if rr.Code != http.StatusOK {
77 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
78 }
79 if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 1 {
80 t.Fatalf("token not minted: %+v (%v)", list, err)
81 }
82}
83
84// A stale session posting a grant form (org members add, on the
85// organization's people page) also sees the refusal and the sign-in
86// link, and the membership is not created.
87func TestWebGrantNeedsRecentSignIn(t *testing.T) {
88 st, err := store.Open(":memory:")
89 if err != nil {
90 t.Fatal(err)
91 }
92 defer st.Close()
93 if err := st.MigrateUp(); err != nil {
94 t.Fatal(err)
95 }
96 uid, err := st.CreateUser("alice", false)
97 if err != nil {
98 t.Fatal(err)
99 }
100 if _, err := st.CreateUser("bob", false); err != nil {
101 t.Fatal(err)
102 }
103 fresh := store.User{ID: uid, Username: "alice", SignedInAt: time.Now()}
104 stale := fresh
105 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
106
107 cfg := config.Default()
108 cfg.Web.Mode = "accounts"
109 s := New(cfg, st, nil)
110 if _, msg, ok := s.runControl(fresh, []string{"org", "create", "krz"}); !ok {
111 t.Fatalf("org create: %s", msg)
112 }
113
114 req := httptest.NewRequest("POST", "/krz",
115 strings.NewReader(url.Values{"field": {"member-add"}, "user": {"bob"}}.Encode()))
116 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
117 req.SetPathValue("owner", "krz")
118 rr := httptest.NewRecorder()
119 s.orgSubmit(rr, req, stale)
120 if rr.Code != http.StatusSeeOther {
121 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
122 }
123
124 req2 := httptest.NewRequest("GET", "/krz/-/people", nil)
125 req2.SetPathValue("owner", "krz")
126 for _, c := range rr.Result().Cookies() {
127 req2.AddCookie(c)
128 }
129 req2.AddCookie(sessionCookieFor(t, s, st, uid))
130 page := httptest.NewRecorder()
131 s.ownerProfile(page, req2)
132 body := page.Body.String()
133 if !strings.Contains(body, control.ReauthRefusal) {
134 t.Fatalf("refusal not shown: %s", body)
135 }
136 if !strings.Contains(body, `<a href="/login">Sign in again</a>`) {
137 t.Fatalf("no sign-in link: %s", body)
138 }
139 var next string
140 for _, c := range page.Result().Cookies() {
141 if c.Name == nextCookie {
142 next = c.Value
143 }
144 }
145 if next != url.QueryEscape("/krz/-/people") {
146 t.Fatalf("gitbay_next = %q, want /krz/-/people", next)
147 }
148
149 org, err := st.OrgByName("krz")
150 if err != nil {
151 t.Fatal(err)
152 }
153 members, _ := st.OrgMembers(org.ID)
154 for _, m := range members {
155 if m.Username == "bob" {
156 t.Fatalf("a stale session added bob to the org")
157 }
158 }
159}
internal/httpd/settings.go +2
@@ -26,6 +26,7 @@ type settingsPage struct {
26 Runners []store.RepoRunner 26 Runners []store.RepoRunner
27 Notice string 27 Notice string
28 Saved bool 28 Saved bool
29 Reauth bool // Notice is the stale-session refusal: link to sign in
29 Submitted map[string]string 30 Submitted map[string]string
30} 31}
31 32
@@ -70,6 +71,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
70 Runners: runners, 71 Runners: runners,
71 Notice: notice, 72 Notice: notice,
72 Saved: strings.HasPrefix(notice, "Saved "), 73 Saved: strings.HasPrefix(notice, "Saved "),
74 Reauth: s.reauthNotice(w, notice, r.URL.Path),
73 Submitted: subm, 75 Submitted: subm,
74 }) 76 })
75} 77}
internal/httpd/web.go +4 −1
@@ -517,6 +517,7 @@ type ownerPage struct {
517 Self bool 517 Self bool
518 Snippets int 518 Snippets int
519 Notice string 519 Notice string
520 Reauth bool // Notice is the stale-session refusal: link to sign in
520 Feed string 521 Feed string
521} 522}
522 523
@@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
572 return 573 return
573 } 574 }
574 } 575 }
576 notice := s.takeFlash(w, r)
575 s.render(w, "owner.html", ownerPage{ 577 s.render(w, "owner.html", ownerPage{
576 basePage: s.baseFor(viewer), 578 basePage: s.baseFor(viewer),
577 Owner: name, 579 Owner: name,
@@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
592 CanAdmin: canAdmin, 594 CanAdmin: canAdmin,
593 Self: self, 595 Self: self,
594 Snippets: d.Snippets, 596 Snippets: d.Snippets,
595 Notice: s.takeFlash(w, r), 597 Notice: notice,
598 Reauth: s.reauthNotice(w, notice, r.URL.Path),
596 Feed: "/" + name + "/activity.atom", 599 Feed: "/" + name + "/activity.atom",
597 }) 600 })
598} 601}
internal/store/sessions.go +16 −6
@@ -80,15 +80,18 @@ func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) e
80 return err 80 return err
81} 81}
82 82
83// WebSessionUser resolves a session cookie hash to its user and renews 83// WebSessionUser resolves a session cookie hash to its user, with the
84// the session's idle expiry. A session is written at most once a 84// session's sign-in time, and renews the session's idle expiry. A
85// minute, so a burst of requests costs one UPDATE. 85// session is written at most once a minute, so a burst of requests
86// costs one UPDATE. Renewal never moves created_at: only a login
87// creates a session, so created_at is when it signed in.
86func (s *Store) WebSessionUser(hash string) (User, error) { 88func (s *Store) WebSessionUser(hash string) (User, error) {
87 now := time.Now() 89 now := time.Now()
88 var userID int64 90 var userID int64
91 var created string
89 err := s.DB.QueryRow( 92 err := s.DB.QueryRow(
90 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", 93 "SELECT user_id, created_at FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
91 hash, fmtTime(now)).Scan(&userID) 94 hash, fmtTime(now)).Scan(&userID, &created)
92 if errors.Is(err, sql.ErrNoRows) { 95 if errors.Is(err, sql.ErrNoRows) {
93 return User{}, ErrNotFound 96 return User{}, ErrNotFound
94 } 97 }
@@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) {
98 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) 101 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
99 WHERE token_hash = ? AND last_used_at < ?`, 102 WHERE token_hash = ? AND last_used_at < ?`,
100 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute))) 103 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
101 return s.UserByID(userID) 104 u, err := s.UserByID(userID)
105 if err != nil {
106 return User{}, err
107 }
108 if t := parseTime(sql.NullString{String: created, Valid: true}); t != nil {
109 u.SignedInAt = *t
110 }
111 return u, nil
102} 112}
103 113
104func (s *Store) DeleteWebSession(hash string) error { 114func (s *Store) DeleteWebSession(hash string) error {
internal/store/sessions_test.go +40
@@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) {
117 t.Fatalf("list: %+v %v", list, err) 117 t.Fatalf("list: %+v %v", list, err)
118 } 118 }
119} 119}
120
121// A session's sign-in time is its creation; using the session renews
122// its idle expiry and leaves the sign-in time alone (#297).
123func TestWebSessionUserSignedInAt(t *testing.T) {
124 s, uid := sessionFixture(t)
125 _, hash, err := NewToken()
126 if err != nil {
127 t.Fatal(err)
128 }
129 if err := s.CreateWebSession(hash, uid, 7*24*time.Hour); err != nil {
130 t.Fatal(err)
131 }
132 u, err := s.WebSessionUser(hash)
133 if err != nil {
134 t.Fatal(err)
135 }
136 if age := time.Since(u.SignedInAt); age < 0 || age > time.Minute {
137 t.Fatalf("fresh session signed in %v ago", age)
138 }
139
140 signedIn := time.Now().Add(-2 * time.Hour)
141 if _, err := s.DB.Exec("UPDATE web_sessions SET created_at = ?, last_used_at = ? WHERE token_hash = ?",
142 fmtTime(signedIn), fmtTime(signedIn), hash); err != nil {
143 t.Fatal(err)
144 }
145 u, err = s.WebSessionUser(hash)
146 if err != nil {
147 t.Fatal(err)
148 }
149 var last string
150 if err := s.DB.QueryRow("SELECT last_used_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&last); err != nil {
151 t.Fatal(err)
152 }
153 if last == fmtTime(signedIn) {
154 t.Fatal("using the session did not renew it")
155 }
156 if want := signedIn.UTC().Truncate(time.Millisecond); !u.SignedInAt.Equal(want) {
157 t.Fatalf("SignedInAt = %v, want %v", u.SignedInAt, want)
158 }
159}
internal/store/users.go +3
@@ -14,6 +14,9 @@ type User struct {
14 IsAdmin bool 14 IsAdmin bool
15 Pending bool // self-registered, email not yet verified 15 Pending bool // self-registered, email not yet verified
16 Disabled bool // administratively suspended 16 Disabled bool // administratively suspended
17 // SignedInAt is when the browser session this user came from was
18 // created by a login. Set by WebSessionUser only; zero elsewhere.
19 SignedInAt time.Time
17} 20}
18 21
19type SSHKey struct { 22type SSHKey struct {
internal/web/templates/account.html +1 −1
@@ -2,7 +2,7 @@
2{{define "title"}}account settings{{end}} 2{{define "title"}}account settings{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Account settings</h1> 4<h1>Account settings</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 5{{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} 6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
7 7
8<div class="withcol narrow"> 8<div class="withcol narrow">
internal/web/templates/adminusers.html +1 −1
@@ -7,7 +7,7 @@
7{{define "content"}} 7{{define "content"}}
8<h1>Accounts</h1> 8<h1>Accounts</h1>
9<p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p> 9<p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p>
10{{if .Notice}}<p class="notice" role="status">{{.Notice}}</p>{{end}} 10{{if .Notice}}<p class="notice" role="status">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
11 11
12<nav class="filters"> 12<nav class="filters">
13 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> 13 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
internal/web/templates/owner.html +1 −1
@@ -83,7 +83,7 @@
83 83
84{{if eq .Tab "people"}}{{$org := .Owner}} 84{{if eq .Tab "people"}}{{$org := .Owner}}
85<h2>people <span class="count">{{len .Members}}</span></h2> 85<h2>people <span class="count">{{len .Members}}</span></h2>
86{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 86{{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
87<div class="tablewrap"><table class="keys"> 87<div class="tablewrap"><table class="keys">
88<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr> 88<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr>
89{{range .Members}}<tr> 89{{range .Members}}<tr>
internal/web/templates/settings.html +1 −1
@@ -3,7 +3,7 @@
3{{define "content"}} 3{{define "content"}}
4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} 4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
5<h1>Settings</h1> 5<h1>Settings</h1>
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} 6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}{{end}}
7<div class="withcol narrow"> 7<div class="withcol narrow">
8<nav class="sidecol" aria-label="Sections"> 8<nav class="sidecol" aria-label="Sections">
9<details class="sidedrop"> 9<details class="sidedrop">