Commit 89dd0a3eba

89dd0a3ebae349ef99ea66c5eedb8bf8792a8eda

parent: 4b94fa96a1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:55 UTC

control: expiring credentials cannot run credential-minting commands

Ref #257

Layout: unified · split

CHANGELOG.org +3
@@ -9,6 +9,9 @@ anything beyond "replace the binary and restart" is needed.
9- Removing an SSH key, removing a deploy key, or disabling or deleting an 9- Removing an SSH key, removing a deploy key, or disabling or deleting an
10 account closes every open connection using an affected key, git 10 account closes every open connection using an affected key, git
11 transports included; every command re-reads its key (#256). 11 transports included; every command re-reads its key (#256).
12- A request whose credential has an expiry cannot run a command that
13 mints another one — tokens, keys, login links, invites, accounts,
14 verified addresses (#257).
12 15
13* v1.36.0 — 2026-09-23 16* v1.36.0 — 2026-09-23
14 17
internal/control/adminhost.go +11 −8
@@ -30,8 +30,9 @@ func init() {
30 {"--verified", "", "mark that address verified", ""}, 30 {"--verified", "", "mark that address verified", ""},
31 {"--key", "-", "read a public key from stdin", ""}, 31 {"--key", "-", "read a public key from stdin", ""},
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, Run: runAdminUserCreate}) 34 ReadsStdin: true,
35 MintsCredential: true, Run: runAdminUserCreate})
35 register(Command{Path: []string{"admin", "user", "disable"}, 36 register(Command{Path: []string{"admin", "user", "disable"},
36 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",
37 Usage: "admin user disable <username>", 38 Usage: "admin user disable <username>",
@@ -51,18 +52,20 @@ func init() {
51 Examples: []string{"admin user delete alice --yes"}, 52 Examples: []string{"admin user delete alice --yes"},
52 Run: runAdminUserDelete}) 53 Run: runAdminUserDelete})
53 register(Command{Path: []string{"admin", "email", "verify"}, 54 register(Command{Path: []string{"admin", "email", "verify"},
54 Summary: "mark an address verified by admin assertion", 55 Summary: "mark an address verified by admin assertion",
55 Usage: "admin email verify <username> <address>", 56 Usage: "admin email verify <username> <address>",
56 Examples: []string{"admin email verify alice alice@example.org"}, 57 Examples: []string{"admin email verify alice alice@example.org"},
57 Run: runAdminEmailVerify}) 58 MintsCredential: true,
59 Run: runAdminEmailVerify})
58 register(Command{Path: []string{"admin", "invite"}, 60 register(Command{Path: []string{"admin", "invite"},
59 Summary: "issue a registration invite and mail its code", 61 Summary: "issue a registration invite and mail its code",
60 Usage: "admin invite --email <address>", 62 Usage: "admin invite --email <address>",
61 Flags: []Flag{ 63 Flags: []Flag{
62 {"--email", "<address>", "who the invite is for", ""}, 64 {"--email", "<address>", "who the invite is for", ""},
63 }, 65 },
64 Examples: []string{"admin invite --email alice@example.org"}, 66 Examples: []string{"admin invite --email alice@example.org"},
65 Run: runAdminInvite}) 67 MintsCredential: true,
68 Run: runAdminInvite})
66 register(Command{Path: []string{"admin", "stats"}, 69 register(Command{Path: []string{"admin", "stats"},
67 Summary: "instance statistics: counts and per-repository disk usage", 70 Summary: "instance statistics: counts and per-repository disk usage",
68 Usage: "admin stats", 71 Usage: "admin stats",
internal/control/control.go +17 −1
@@ -40,6 +40,13 @@ type Ctx struct {
40 // Source identifies the credential behind this session for the audit 40 // Source identifies the credential behind this session for the audit
41 // log: an SSH key fingerprint, or "api" for token requests. 41 // log: an SSH key fingerprint, or "api" for token requests.
42 Source string 42 Source string
43 // TokenID is the API token behind this request, 0 for none. A
44 // credential the request creates records it.
45 TokenID int64
46 // Expires is when the credential behind this request lapses; nil
47 // when it does not. Dispatch refuses MintsCredential commands when
48 // it is set.
49 Expires *time.Time
43 // Cmd is the command being run, set by Dispatch, so a usage error can 50 // Cmd is the command being run, set by Dispatch, so a usage error can
44 // print the registered usage rather than a copy of it. 51 // print the registered usage rather than a copy of it.
45 Cmd Command 52 Cmd Command
@@ -87,7 +94,11 @@ type Command struct {
87 Examples []string // full argv after the program, repository named 94 Examples []string // full argv after the program, repository named
88 ReadsStdin bool 95 ReadsStdin bool
89 ReadOnly bool // safe for read-scoped API tokens 96 ReadOnly bool // safe for read-scoped API tokens
90 Run func(c *Ctx, args []string) int 97 // MintsCredential marks a command that creates a credential or a way
98 // to obtain one: tokens, keys, login links, invites, accounts,
99 // verified addresses. An expiring credential may not run it.
100 MintsCredential bool
101 Run func(c *Ctx, args []string) int
91} 102}
92 103
93var registry []Command 104var registry []Command
@@ -159,6 +170,11 @@ func Dispatch(c *Ctx, argv []string) int {
159 if c.ReadOnly && !cmd.ReadOnly { 170 if c.ReadOnly && !cmd.ReadOnly {
160 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) 171 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
161 } 172 }
173 // What an expiring credential creates would outlive it (#257).
174 if cmd.MintsCredential && c.Expires != nil {
175 return c.fail(protocol.ExitDenied,
176 "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
177 }
162 // The SSH listener refuses a disabled account before it gets here; the 178 // The SSH listener refuses a disabled account before it gets here; the
163 // API and the web reach Dispatch directly, so the check lives here too. 179 // API and the web reach Dispatch directly, so the check lives here too.
164 if c.User.Disabled { 180 if c.User.Disabled {
internal/control/deploykey.go +3 −2
@@ -19,8 +19,9 @@ func init() {
19 Flags: []Flag{ 19 Flags: []Flag{
20 {"--rw", "", "the key may push, not just fetch", ""}, 20 {"--rw", "", "the key may push, not just fetch", ""},
21 }, 21 },
22 Examples: []string{"repo deploy-key add krz/gitbay < key.pub"}, 22 Examples: []string{"repo deploy-key add krz/gitbay < key.pub"},
23 ReadsStdin: true, Run: runDeployKeyAdd}) 23 ReadsStdin: true,
24 MintsCredential: true, Run: runDeployKeyAdd})
24 register(Command{Path: []string{"repo", "deploy-key", "list"}, 25 register(Command{Path: []string{"repo", "deploy-key", "list"},
25 Summary: "list deploy keys", 26 Summary: "list deploy keys",
26 Usage: "repo deploy-key list <owner/name>", 27 Usage: "repo deploy-key list <owner/name>",
internal/control/identity.go +4 −3
@@ -38,9 +38,10 @@ func init() {
38 {"--scope", "full|git|runner", "what the key may do", "full"}, 38 {"--scope", "full|git|runner", "what the key may do", "full"},
39 {"--label", "<text>", "a name for the key", ""}, 39 {"--label", "<text>", "a name for the key", ""},
40 }, 40 },
41 Examples: []string{"keys add --label laptop < key.pub"}, 41 Examples: []string{"keys add --label laptop < key.pub"},
42 ReadsStdin: true, 42 ReadsStdin: true,
43 Run: runKeysAdd, 43 MintsCredential: true,
44 Run: runKeysAdd,
44 }) 45 })
45 register(Command{ 46 register(Command{
46 Path: []string{"keys", "label"}, 47 Path: []string{"keys", "label"},
internal/control/register.go +4 −3
@@ -36,9 +36,10 @@ func init() {
36 Usage: "email add <address>", 36 Usage: "email add <address>",
37 Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd}) 37 Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd})
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 Examples: []string{"email verify abc123"}, Run: runEmailVerify}) 41 MintsCredential: true,
42 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
42 register(Command{Path: []string{"email", "list"}, 43 register(Command{Path: []string{"email", "list"},
43 Summary: "list the addresses on your account", 44 Summary: "list the addresses on your account",
44 Usage: "email list", 45 Usage: "email list",
internal/control/runnerrepo.go +5 −4
@@ -19,10 +19,11 @@ import (
19// read-only git. 19// read-only git.
20func init() { 20func init() {
21 register(Command{Path: []string{"repo", "runner", "add"}, 21 register(Command{Path: []string{"repo", "runner", "add"},
22 Summary: "attach a runner's public key to a repository", 22 Summary: "attach a runner's public key to a repository",
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, Run: runRepoRunnerAdd}) 25 ReadsStdin: true,
26 MintsCredential: true, Run: runRepoRunnerAdd})
26 register(Command{Path: []string{"repo", "runner", "list"}, 27 register(Command{Path: []string{"repo", "runner", "list"},
27 Summary: "list the runners attached to a repository", 28 Summary: "list the runners attached to a repository",
28 Usage: "repo runner list <owner/name>", 29 Usage: "repo runner list <owner/name>",
internal/control/token.go +5 −4
@@ -21,8 +21,9 @@ func init() {
21 {"--scope", "full|read", "what the token may do", "full"}, 21 {"--scope", "full|read", "what the token may do", "full"},
22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"}, 22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"},
23 }, 23 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"}, 24 Examples: []string{"token create --name laptop --scope read --ttl 30d"},
25 Run: runTokenCreate}) 25 MintsCredential: true,
26 Run: runTokenCreate})
26 register(Command{Path: []string{"token", "list"}, 27 register(Command{Path: []string{"token", "list"},
27 Summary: "list API tokens", 28 Summary: "list API tokens",
28 Usage: "token list", 29 Usage: "token list",
@@ -73,7 +74,7 @@ func runTokenCreate(c *Ctx, args []string) int {
73 } 74 }
74 // The gb_ prefix makes leaked tokens findable by secret scanners. 75 // The gb_ prefix makes leaked tokens findable by secret scanners.
75 token := "gb_" + raw 76 token := "gb_" + raw
76 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil { 77 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
77 return c.failErr(err) 78 return c.failErr(err)
78 } 79 }
79 type out struct { 80 type out struct {
@@ -125,7 +126,7 @@ func runTokenRevoke(c *Ctx, args []string) int {
125 if len(args) != 1 { 126 if len(args) != 1 {
126 return c.usage() 127 return c.usage()
127 } 128 }
128 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil { 129 if _, err := c.Store.RevokeAPIToken(c.User.ID, args[0], false); err != nil {
129 if errors.Is(err, store.ErrNotFound) { 130 if errors.Is(err, store.ErrNotFound) {
130 return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) 131 return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
131 } 132 }
internal/control/token_test.go added +72
@@ -0,0 +1,72 @@
1package control
2
3import (
4 "bytes"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// The minting commands, pinned: adding one to the list, or dropping
15// one, is a decision this test makes someone take.
16func TestMintingCommandsMarked(t *testing.T) {
17 want := []string{
18 "admin email verify", "admin invite", "admin user create", "email verify",
19 "keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
20 }
21 var got []string
22 for _, cmd := range Commands() {
23 if cmd.MintsCredential {
24 got = append(got, joinPath(cmd.Path))
25 }
26 }
27 slices.Sort(got)
28 if !slices.Equal(got, want) {
29 t.Fatalf("MintsCredential on %q, want %q", got, want)
30 }
31}
32
33// Dispatch refuses before the command runs, so no arguments are needed.
34func TestExpiringCredentialCannotMint(t *testing.T) {
35 exp := time.Now().Add(time.Hour)
36 for _, cmd := range Commands() {
37 if !cmd.MintsCredential {
38 continue
39 }
40 var out, errOut bytes.Buffer
41 c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
42 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
43 t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
44 }
45 }
46}
47
48func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
49 st, _, uid := newQueueTestRepo(t)
50 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
51 t.Fatal(err)
52 }
53 _, parent, err := st.APITokenUser("h-parent")
54 if err != nil {
55 t.Fatal(err)
56 }
57 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
58 c.Cfg.Limits.WriteRate = -1
59 c.TokenID = parent.ID
60 if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
61 t.Fatalf("exit %d: %s", code, errOut)
62 }
63 toks, err := st.ListAPITokens(uid)
64 if err != nil {
65 t.Fatal(err)
66 }
67 for _, tk := range toks {
68 if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
69 t.Fatalf("child: %+v", tk)
70 }
71 }
72}
internal/control/web.go +4 −3
@@ -14,9 +14,10 @@ func newStoredToken() (token, hash string, err error) { return store.NewToken()
14 14
15func init() { 15func 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 Examples: []string{"web login"}, Run: runWebLogin}) 19 MintsCredential: true,
20 Examples: []string{"web login"}, Run: runWebLogin})
20 register(Command{Path: []string{"web", "sessions", "list"}, 21 register(Command{Path: []string{"web", "sessions", "list"},
21 Summary: "list your browser sessions", 22 Summary: "list your browser sessions",
22 Usage: "web sessions list", 23 Usage: "web sessions list",
internal/httpd/api.go +10 −8
@@ -28,7 +28,7 @@ const maxAPIBody = 1 << 20
28// semantics. Exit codes map onto HTTP statuses; the body is the command's 28// semantics. Exit codes map onto HTTP statuses; the body is the command's
29// JSON envelope with exit_code added. 29// JSON envelope with exit_code added.
30func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { 30func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
31 user, scope, ok := s.apiAuth(w, r) 31 user, tok, ok := s.apiAuth(w, r)
32 if !ok { 32 if !ok {
33 return 33 return
34 } 34 }
@@ -72,7 +72,9 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
72 Stderr: &stderr, 72 Stderr: &stderr,
73 JSON: true, 73 JSON: true,
74 ViaAPI: true, 74 ViaAPI: true,
75 ReadOnly: scope == "read", 75 ReadOnly: tok.Scope == "read",
76 TokenID: tok.ID,
77 Expires: tok.ExpiresAt,
76 Done: s.until(r), 78 Done: s.until(r),
77 Stopping: s.stopping, 79 Stopping: s.stopping,
78 } 80 }
@@ -124,23 +126,23 @@ func (s *Server) limitKey(r *http.Request, user store.User) string {
124} 126}
125 127
126// apiAuth resolves the bearer token; failures are uniform 401s. 128// apiAuth resolves the bearer token; failures are uniform 401s.
127func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { 129func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
128 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") 130 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
129 if !ok || token == "" { 131 if !ok || token == "" {
130 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) 132 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
131 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") 133 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
132 return store.User{}, "", false 134 return store.User{}, store.APIToken{}, false
133 } 135 }
134 user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) 136 user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
135 if err != nil { 137 if err != nil {
136 if errors.Is(err, store.ErrNotFound) { 138 if errors.Is(err, store.ErrNotFound) {
137 apiError(w, http.StatusUnauthorized, "invalid or expired token") 139 apiError(w, http.StatusUnauthorized, "invalid or expired token")
138 return store.User{}, "", false 140 return store.User{}, store.APIToken{}, false
139 } 141 }
140 apiError(w, http.StatusInternalServerError, "internal error") 142 apiError(w, http.StatusInternalServerError, "internal error")
141 return store.User{}, "", false 143 return store.User{}, store.APIToken{}, false
142 } 144 }
143 return user, scope, true 145 return user, tok, true
144} 146}
145 147
146func apiError(w http.ResponseWriter, status int, msg string) { 148func apiError(w http.ResponseWriter, status int, msg string) {