Commit b79de56262

b79de56262481b9e3e545165fe3888e63c528415

parent: 887952c35a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:10 UTC

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

Ref #297

Layout: unified · split

internal/control/admin.go +5 −4
@@ -31,10 +31,11 @@ func init() {
3131 Examples: []string{"admin user show alice"},
3232 ReadOnly: true, Run: runAdminUserShow})
3333 register(Command{Path: []string{"admin", "user", "promote"},
34 Summary: "make an account an instance admin",
35 Usage: "admin user promote <username>",
36 Examples: []string{"admin user promote alice"},
37 Run: runAdminUserPromote})
34 NeedsRecentSignIn: true,
35 Summary: "make an account an instance admin",
36 Usage: "admin user promote <username>",
37 Examples: []string{"admin user promote alice"},
38 Run: runAdminUserPromote})
3839 register(Command{Path: []string{"admin", "user", "demote"},
3940 Summary: "remove instance admin from an account (never the last one)",
4041 Usage: "admin user demote <username>",
internal/control/adminhost.go +10 −9
@@ -32,17 +32,18 @@ func init() {
3232 },
3333 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"},
3434 ReadsStdin: true,
35 MintsCredential: true, Run: runAdminUserCreate})
35 MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate})
3636 register(Command{Path: []string{"admin", "user", "disable"},
3737 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
3838 Usage: "admin user disable <username>",
3939 Examples: []string{"admin user disable alice"},
4040 Run: runAdminUserDisable})
4141 register(Command{Path: []string{"admin", "user", "enable"},
42 Summary: "restore a suspended account",
43 Usage: "admin user enable <username>",
44 Examples: []string{"admin user enable alice"},
45 Run: runAdminUserEnable})
42 NeedsRecentSignIn: true,
43 Summary: "restore a suspended account",
44 Usage: "admin user enable <username>",
45 Examples: []string{"admin user enable alice"},
46 Run: runAdminUserEnable})
4647 register(Command{Path: []string{"admin", "user", "delete"},
4748 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
4849 Usage: "admin user delete <username> --yes",
@@ -55,8 +56,8 @@ func init() {
5556 Summary: "mark an address verified by admin assertion",
5657 Usage: "admin email verify <username> <address>",
5758 Examples: []string{"admin email verify alice alice@example.org"},
58 MintsCredential: true,
59 Run: runAdminEmailVerify})
59 MintsCredential: true, NeedsRecentSignIn: true,
60 Run: runAdminEmailVerify})
6061 register(Command{Path: []string{"admin", "invite"},
6162 Summary: "issue a registration invite and mail its code",
6263 Usage: "admin invite --email <address>",
@@ -64,8 +65,8 @@ func init() {
6465 {"--email", "<address>", "who the invite is for", ""},
6566 },
6667 Examples: []string{"admin invite --email alice@example.org"},
67 MintsCredential: true,
68 Run: runAdminInvite})
68 MintsCredential: true, NeedsRecentSignIn: true,
69 Run: runAdminInvite})
6970 register(Command{Path: []string{"admin", "stats"},
7071 Summary: "instance statistics: counts and per-repository disk usage",
7172 Usage: "admin stats",
internal/control/build.go +5 −4
@@ -69,10 +69,11 @@ func init() {
6969 // repo's builds as environment variables. Same discipline as mirror
7070 // tokens — the value never appears in argv, logs, or output.
7171 register(Command{Path: []string{"repo", "secret", "set"},
72 Summary: "set a build secret",
73 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
74 Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"},
75 ReadsStdin: true, Run: runSecretSet})
72 NeedsRecentSignIn: true,
73 Summary: "set a build secret",
74 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
75 Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"},
76 ReadsStdin: true, Run: runSecretSet})
7677 register(Command{Path: []string{"repo", "secret", "remove"},
7778 Summary: "remove a build secret",
7879 Usage: "repo secret remove <owner/name> <NAME>",
internal/control/control.go +30 −1
@@ -69,6 +69,27 @@ type Ctx struct {
6969 Stopping <-chan struct{}
7070}
7171
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
7293// usage reports a bad invocation with the command's registered usage,
7394// the one source of it.
7495func (c *Ctx) usage() int {
@@ -104,7 +125,12 @@ type Command struct {
104125 // to obtain one: tokens, keys, login links, invites, accounts,
105126 // verified addresses. An expiring credential may not run it.
106127 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
108134}
109135
110136var registry []Command
@@ -212,6 +238,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int {
212238 if c.User.Disabled {
213239 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
214240 }
241 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
242 return c.fail(protocol.ExitDenied, "%s", ReauthRefusal)
243 }
215244 // The admin noun is gated here as well as in each handler, so a new
216245 // admin command that forgets requireInstanceAdmin is still refused.
217246 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
internal/control/deploykey.go +1 −1
@@ -23,7 +23,7 @@ func init() {
2323 },
2424 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
2525 ReadsStdin: true,
26 MintsCredential: true, Run: runDeployKeyAdd})
26 MintsCredential: true, NeedsRecentSignIn: true, Run: runDeployKeyAdd})
2727 register(Command{Path: []string{"repo", "deploy-key", "list"},
2828 Summary: "list deploy keys",
2929 Usage: "repo deploy-key list <owner/name>",
internal/control/identity.go +2 −2
@@ -42,8 +42,8 @@ func init() {
4242 },
4343 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
4444 ReadsStdin: true,
45 MintsCredential: true,
46 Run: runKeysAdd,
45 MintsCredential: true, NeedsRecentSignIn: true,
46 Run: runKeysAdd,
4747 })
4848 register(Command{
4949 Path: []string{"keys", "label"},
internal/control/mirrorcmd.go +3 −2
@@ -16,8 +16,9 @@ import (
1616
1717func init() {
1818 register(Command{Path: []string{"repo", "mirror", "add"},
19 Summary: "mirror to or from a remote",
20 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]",
19 NeedsRecentSignIn: true,
20 Summary: "mirror to or from a remote",
21 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]",
2122 Flags: []Flag{
2223 {"--direction", "push|pull", "which way the mirror syncs", ""},
2324 {"--username", "<u>", "the remote's username", ""},
internal/control/notifications.go +3 −2
@@ -48,8 +48,9 @@ func init() {
4848 Examples: []string{"notifications settings watch on"},
4949 Run: runNotificationsSettingsWatch})
5050 register(Command{Path: []string{"notifications", "device", "add"},
51 Summary: "register an Apple device for push, token on stdin",
52 Usage: "notifications device add [--label <name>] < token",
51 NeedsRecentSignIn: true,
52 Summary: "register an Apple device for push, token on stdin",
53 Usage: "notifications device add [--label <name>] < token",
5354 Flags: []Flag{
5455 {"--label", "<name>", "a name for the device", ""},
5556 },
internal/control/org.go +3 −2
@@ -37,8 +37,9 @@ func init() {
3737 },
3838 Examples: []string{"org delete krz --yes"}, Run: runOrgDelete})
3939 register(Command{Path: []string{"org", "members", "add"},
40 Summary: "add or update a member",
41 Usage: "org members add <org> <user> [--role member|admin]",
40 NeedsRecentSignIn: true,
41 Summary: "add or update a member",
42 Usage: "org members add <org> <user> [--role member|admin]",
4243 Flags: []Flag{
4344 {"--role", "member|admin", "the member's role", "member"},
4445 },
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() {
3838 register(Command{Path: []string{"email", "verify"},
3939 Summary: "confirm a verification code",
4040 Usage: "email verify <code>",
41 MintsCredential: true,
42 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
41 MintsCredential: true, NeedsRecentSignIn: true,
42 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
4343 register(Command{Path: []string{"email", "list"},
4444 Summary: "list the addresses on your account",
4545 Usage: "email list",
internal/control/repo.go +10 −8
@@ -50,10 +50,11 @@ func init() {
5050 Examples: []string{"repo show krz/gitbay"},
5151 ReadOnly: true, Run: runRepoShow})
5252 register(Command{Path: []string{"repo", "transfer"},
53 Summary: "move a repository to another owner",
54 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)",
55 Examples: []string{"repo transfer krz/gitbay krazywarez"},
56 Run: runRepoTransfer})
53 NeedsRecentSignIn: true,
54 Summary: "move a repository to another owner",
55 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)",
56 Examples: []string{"repo transfer krz/gitbay krazywarez"},
57 Run: runRepoTransfer})
5758 register(Command{Path: []string{"repo", "rename"},
5859 Summary: "rename a repository",
5960 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
@@ -68,10 +69,11 @@ func init() {
6869 Examples: []string{"repo delete cmc/scratch --yes"},
6970 Run: runRepoDelete})
7071 register(Command{Path: []string{"repo", "access", "grant"},
71 Summary: "grant access",
72 Usage: "repo access grant <owner/name> <user> read|write|admin",
73 Examples: []string{"repo access grant krz/gitbay cmc write"},
74 Run: runAccessGrant})
72 NeedsRecentSignIn: true,
73 Summary: "grant access",
74 Usage: "repo access grant <owner/name> <user> read|write|admin",
75 Examples: []string{"repo access grant krz/gitbay cmc write"},
76 Run: runAccessGrant})
7577 register(Command{Path: []string{"repo", "access", "revoke"},
7678 Summary: "revoke access",
7779 Usage: "repo access revoke <owner/name> <user>",
internal/control/runnerrepo.go +1 −1
@@ -23,7 +23,7 @@ func init() {
2323 Usage: "repo runner add <owner/name> < key.pub",
2424 Examples: []string{"repo runner add krz/gitbay < key.pub"},
2525 ReadsStdin: true,
26 MintsCredential: true, Run: runRepoRunnerAdd})
26 MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd})
2727 register(Command{Path: []string{"repo", "runner", "list"},
2828 Summary: "list the runners attached to a repository",
2929 Usage: "repo runner list <owner/name>",
internal/control/sig.go +5 −4
@@ -18,10 +18,11 @@ import (
1818
1919func init() {
2020 register(Command{Path: []string{"pgp", "add"},
21 Summary: "register an OpenPGP public key (armored)",
22 Usage: "pgp add < key.asc",
23 Examples: []string{"pgp add < key.asc"},
24 ReadsStdin: true, Run: runPGPAdd})
21 NeedsRecentSignIn: true,
22 Summary: "register an OpenPGP public key (armored)",
23 Usage: "pgp add < key.asc",
24 Examples: []string{"pgp add < key.asc"},
25 ReadsStdin: true, Run: runPGPAdd})
2526 register(Command{Path: []string{"pgp", "list"},
2627 Summary: "list registered OpenPGP keys",
2728 Usage: "pgp list",
internal/control/teams.go +12 −9
@@ -30,25 +30,28 @@ func init() {
3030 Usage: "org team show <org> <team>",
3131 Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow})
3232 register(Command{Path: []string{"org", "team", "add"},
33 Summary: "add org members to a team",
34 Usage: "org team add <org> <team> <user>...",
35 Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd})
33 NeedsRecentSignIn: true,
34 Summary: "add org members to a team",
35 Usage: "org team add <org> <team> <user>...",
36 Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd})
3637 register(Command{Path: []string{"org", "team", "remove"},
3738 Summary: "remove members from a team",
3839 Usage: "org team remove <org> <team> <user>...",
3940 Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove})
4041 register(Command{Path: []string{"org", "team", "grant"},
41 Summary: "grant a team a role on an org repo",
42 Usage: "org team grant <org> <team> <owner/name> read|write|admin",
43 Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant})
42 NeedsRecentSignIn: true,
43 Summary: "grant a team a role on an org repo",
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})
4446 register(Command{Path: []string{"org", "team", "revoke"},
4547 Summary: "revoke a team's grant",
4648 Usage: "org team revoke <org> <team> <owner/name>",
4749 Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke})
4850 register(Command{Path: []string{"org", "settings", "members-role"},
49 Summary: "role plain membership implies on every org repo",
50 Usage: "org settings members-role <org> write|read|none (default write)",
51 Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole})
51 NeedsRecentSignIn: true,
52 Summary: "role plain membership implies on every org repo",
53 Usage: "org settings members-role <org> write|read|none (default write)",
54 Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole})
5255}
5356
5457// orgAdminRef resolves an org and requires the caller to admin it.
internal/control/token.go +2 −2
@@ -22,8 +22,8 @@ func init() {
2222 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
2323 },
2424 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
25 MintsCredential: true,
26 Run: runTokenCreate})
25 MintsCredential: true, NeedsRecentSignIn: true,
26 Run: runTokenCreate})
2727 register(Command{Path: []string{"token", "list"},
2828 Summary: "list API tokens",
2929 Usage: "token list",
internal/control/web.go +2 −2
@@ -16,8 +16,8 @@ func init() {
1616 register(Command{Path: []string{"web", "login"},
1717 Summary: "mint a one-time browser login URL",
1818 Usage: "web login",
19 MintsCredential: true,
20 Examples: []string{"web login"}, Run: runWebLogin})
19 MintsCredential: true, NeedsRecentSignIn: true,
20 Examples: []string{"web login"}, Run: runWebLogin})
2121 register(Command{Path: []string{"web", "sessions", "list"},
2222 Summary: "list your browser sessions",
2323 Usage: "web sessions list",
internal/control/webhook.go +3 −2
@@ -15,8 +15,9 @@ import (
1515
1616func init() {
1717 register(Command{Path: []string{"webhook", "add"},
18 Summary: "add a webhook",
19 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
18 NeedsRecentSignIn: true,
19 Summary: "add a webhook",
20 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
2021 Flags: []Flag{
2122 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
2223 {"--events", "push,issue.created|*", "which events to send", "*"},
internal/httpd/account_test.go +2 −1
@@ -7,6 +7,7 @@ import (
77 "strconv"
88 "strings"
99 "testing"
10 "time"
1011
1112 "gitbay.org/gitbay/internal/config"
1213 "gitbay.org/gitbay/internal/store"
@@ -300,7 +301,7 @@ func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
300301 if err != nil {
301302 t.Fatal(err)
302303 }
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()}
304305}
305306
306307// The settings page lists a user's API tokens with scope and expiry,