email verify: say when a code belongs to another account !109
merged
merged by cmc on 2026-08-28 22:13 UTC
· krz/gitbay:verify-wrong-account into main
7 files changed, +51 −27
Layout: unified · split
internal/control/register.go
+13 −1
| @@ -73,9 +73,21 @@ func runEmailVerify(c *Ctx, args []string) int { |
| 73 | if len(args) != 1 { |
73 | if len(args) != 1 { |
| 74 | return c.fail(protocol.ExitUsage, "usage: email verify <code>") |
74 | return c.fail(protocol.ExitUsage, "usage: email verify <code>") |
| 75 | } |
75 | } |
| 76 | address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0])) |
76 | hash := store.HashToken(args[0]) |
| |
77 | address, err := c.Store.ConsumeEmailToken(c.User.ID, hash) |
| 77 | if err != nil { |
78 | if err != nil { |
| 78 | if errors.Is(err, store.ErrNotFound) { |
79 | if errors.Is(err, store.ErrNotFound) { |
| |
80 | // A code is scoped to the account that asked for it. Running |
| |
81 | // this with the wrong key authenticates as the wrong account |
| |
82 | // and looks exactly like a bad code, which is misleading when |
| |
83 | // the code is fine and the key is not. |
| |
84 | if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other { |
| |
85 | return c.fail(protocol.ExitDenied, |
| |
86 | "that code belongs to a different account; this key authenticated you as %s. "+ |
| |
87 | "Re-run with the key registered to the account being verified: "+ |
| |
88 | "ssh -i <that key> git@<host> email verify <code>", |
| |
89 | c.User.Username) |
| |
90 | } |
| 79 | return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") |
91 | return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") |
| 80 | } |
92 | } |
| 81 | return c.fail(protocol.ExitFailure, "%v", err) |
93 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/policy/codeowners_test.go
+4 −4
| @@ -24,10 +24,10 @@ internal/* @grace |
| 24 | {"main.go", []string{"bob", "carol"}}, |
24 | {"main.go", []string{"bob", "carol"}}, |
| 25 | {"deep/nested/thing.go", []string{"bob", "carol"}}, |
25 | {"deep/nested/thing.go", []string{"bob", "carol"}}, |
| 26 | {"docs/users.org", []string{"dana"}}, |
26 | {"docs/users.org", []string{"dana"}}, |
| 27 | {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere |
27 | {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere |
| 28 | {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir |
28 | {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir |
| 29 | {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) |
29 | {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) |
| 30 | {"internal/policy", []string{"grace"}}, // single-segment glob |
30 | {"internal/policy", []string{"grace"}}, // single-segment glob |
| 31 | } |
31 | } |
| 32 | for _, tc := range cases { |
32 | for _, tc := range cases { |
| 33 | if got := OwnersFor(rules, tc.path); !reflect.DeepEqual(got, tc.want) { |
33 | if got := OwnersFor(rules, tc.path); !reflect.DeepEqual(got, tc.want) { |
internal/policy/names_test.go
+11 −11
| @@ -12,17 +12,17 @@ func TestValidateOwnerName(t *testing.T) { |
| 12 | |
12 | |
| 13 | invalid := []string{ |
13 | invalid := []string{ |
| 14 | "", |
14 | "", |
| 15 | "Alice", // uppercase |
15 | "Alice", // uppercase |
| 16 | "-lead", // bad first char |
16 | "-lead", // bad first char |
| 17 | ".hidden", // bad first char |
17 | ".hidden", // bad first char |
| 18 | "a b", // space |
18 | "a b", // space |
| 19 | "repo.git", // .git suffix |
19 | "repo.git", // .git suffix |
| 20 | "..", // |
20 | "..", // |
| 21 | "login", // reserved |
21 | "login", // reserved |
| 22 | "admin", // reserved |
22 | "admin", // reserved |
| 23 | "static", // reserved |
23 | "static", // reserved |
| 24 | "api", // reserved |
24 | "api", // reserved |
| 25 | "register", // reserved |
25 | "register", // reserved |
| 26 | } |
26 | } |
| 27 | for _, n := range invalid { |
27 | for _, n := range invalid { |
| 28 | if err := ValidateOwnerName(n); err == nil { |
28 | if err := ValidateOwnerName(n); err == nil { |
internal/protocol/protocol.go
+6 −6
| @@ -10,12 +10,12 @@ const Version = 1 |
| 10 | |
10 | |
| 11 | // Exit codes shared by the CLI and by control commands run over bare ssh. |
11 | // Exit codes shared by the CLI and by control commands run over bare ssh. |
| 12 | const ( |
12 | const ( |
| 13 | ExitOK = 0 |
13 | ExitOK = 0 |
| 14 | ExitFailure = 1 // general failure |
14 | ExitFailure = 1 // general failure |
| 15 | ExitUsage = 2 // usage error |
15 | ExitUsage = 2 // usage error |
| 16 | ExitNotFound = 3 |
16 | ExitNotFound = 3 |
| 17 | ExitDenied = 4 |
17 | ExitDenied = 4 |
| 18 | ExitProtocol = 5 // server/protocol error |
18 | ExitProtocol = 5 // server/protocol error |
| 19 | ) |
19 | ) |
| 20 | |
20 | |
| 21 | // Envelope wraps every JSON response from a control command. |
21 | // Envelope wraps every JSON response from a control command. |
internal/store/registration.go
+12
| @@ -53,6 +53,18 @@ func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error |
| 53 | return address, err |
53 | return address, err |
| 54 | } |
54 | } |
| 55 | |
55 | |
| |
56 | // EmailTokenBelongsToAnotherUser reports whether a live code exists but |
| |
57 | // is owned by someone else. It answers only yes or no: naming the owner |
| |
58 | // would turn a guessed code into an account oracle. |
| |
59 | func (s *Store) EmailTokenBelongsToAnotherUser(userID int64, tokenHash string) (bool, error) { |
| |
60 | var n int |
| |
61 | err := s.DB.QueryRow(` |
| |
62 | SELECT COUNT(*) FROM email_tokens |
| |
63 | WHERE token_hash = ? AND user_id != ? AND used_at IS NULL AND expires_at > ?`, |
| |
64 | tokenHash, userID, fmtTime(time.Now())).Scan(&n) |
| |
65 | return n > 0, err |
| |
66 | } |
| |
67 | |
| 56 | // CreateRegisteredUser makes a self-registered account, pending until its |
68 | // CreateRegisteredUser makes a self-registered account, pending until its |
| 57 | // email is verified. |
69 | // email is verified. |
| 58 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { |
70 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { |
internal/store/webhooks.go
+1 −1
| @@ -1,7 +1,7 @@ |
| 1 | package store |
1 | package store |
| 2 | |
2 | |
| 3 | import ( |
3 | import ( |
| 4 | "time" |
4 | "time" |
| 5 | ) |
5 | ) |
| 6 | |
6 | |
| 7 | type Webhook struct { |
7 | type Webhook struct { |
internal/webhook/webhook.go
+4 −4
| @@ -55,11 +55,11 @@ func isForbidden(ip net.IP) bool { |
| 55 | } |
55 | } |
| 56 | |
56 | |
| 57 | type Deliverer struct { |
57 | type Deliverer struct { |
| 58 | St *store.Store |
58 | St *store.Store |
| 59 | AllowLocal bool |
59 | AllowLocal bool |
| 60 | RetryBase time.Duration // first retry delay; doubles per attempt |
60 | RetryBase time.Duration // first retry delay; doubles per attempt |
| 61 | MaxAttempts int |
61 | MaxAttempts int |
| 62 | client *http.Client |
62 | client *http.Client |
| 63 | } |
63 | } |
| 64 | |
64 | |
| 65 | // New builds a deliverer whose dialer re-checks resolved addresses at |
65 | // New builds a deliverer whose dialer re-checks resolved addresses at |