Commit 135af80d08
Verified · cmc
Layout: unified · split
internal/hookd/hookd.go +36
| @@ -36,6 +36,10 @@ const ( | ||
| 36 | 36 | EnvRepoID = "GITBAY_REPO_ID" |
| 37 | 37 | EnvUserID = "GITBAY_USER_ID" |
| 38 | 38 | EnvScope = "GITBAY_KEY_SCOPE" |
| 39 | // EnvToken names the receive-pack this hook runs under. sshd mints | |
| 40 | // it per push; hookd answers only a request carrying a live one | |
| 41 | // whose repository, account and scope match the request's. | |
| 42 | EnvToken = "GITBAY_PUSH_TOKEN" | |
| 39 | 43 | ) |
| 40 | 44 | |
| 41 | 45 | type Request struct { |
| @@ -46,6 +50,7 @@ type Request struct { | ||
| 46 | 50 | // the key belongs to, and a deploy key grants nothing outside its |
| 47 | 51 | // binding, so anything acting on another repository needs this too. |
| 48 | 52 | Scope string `json:"scope"` |
| 53 | Token string `json:"token"` | |
| 49 | 54 | Updates []policy.RefUpdate `json:"updates"` |
| 50 | 55 | } |
| 51 | 56 | |
| @@ -94,6 +99,12 @@ func Serve(cfg config.Config, st *store.Store) (func() error, error) { | ||
| 94 | 99 | if err != nil { |
| 95 | 100 | return nil, err |
| 96 | 101 | } |
| 102 | // Listen creates the socket under the process umask. Hooks run as | |
| 103 | // the daemon's own user; nobody else has a reason to connect. | |
| 104 | if err := os.Chmod(path, 0o600); err != nil { | |
| 105 | ln.Close() | |
| 106 | return nil, err | |
| 107 | } | |
| 97 | 108 | s := &Server{cfg: cfg, st: st} |
| 98 | 109 | go func() { |
| 99 | 110 | for { |
| @@ -111,11 +122,20 @@ func (s *Server) handle(conn net.Conn) { | ||
| 111 | 122 | defer conn.Close() |
| 112 | 123 | dec := json.NewDecoder(conn) |
| 113 | 124 | enc := json.NewEncoder(conn) |
| 125 | if err := checkPeer(conn); err != nil { | |
| 126 | slog.Warn("hook socket: refused connection", "err", err) | |
| 127 | enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()}) | |
| 128 | return | |
| 129 | } | |
| 114 | 130 | var req Request |
| 115 | 131 | if err := dec.Decode(&req); err != nil { |
| 116 | 132 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) |
| 117 | 133 | return |
| 118 | 134 | } |
| 135 | if msg := s.authorize(req); msg != "" { | |
| 136 | enc.Encode(Response{Allow: false, Message: msg}) | |
| 137 | return | |
| 138 | } | |
| 119 | 139 | switch req.Hook { |
| 120 | 140 | case "pre-receive": |
| 121 | 141 | s.preReceive(req, dec, enc) |
| @@ -127,6 +147,22 @@ func (s *Server) handle(conn net.Conn) { | ||
| 127 | 147 | } |
| 128 | 148 | } |
| 129 | 149 | |
| 150 | // authorize ties a request to a receive-pack sshd started: its token | |
| 151 | // must be live and name the same repository, account and key scope. | |
| 152 | func (s *Server) authorize(req Request) string { | |
| 153 | if req.Token == "" { | |
| 154 | return "push not started by this server" | |
| 155 | } | |
| 156 | tok, err := s.st.PushTokenByHash(store.HashToken(req.Token)) | |
| 157 | if err != nil { | |
| 158 | return "push not started by this server" | |
| 159 | } | |
| 160 | if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope { | |
| 161 | return "push token does not match this request" | |
| 162 | } | |
| 163 | return "" | |
| 164 | } | |
| 165 | ||
| 130 | 166 | func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { |
| 131 | 167 | repo, err := s.st.RepoByID(req.RepoID) |
| 132 | 168 | if err != nil { |
internal/hookd/peercred_linux.go added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | //go:build linux | |
| 2 | ||
| 3 | package hookd | |
| 4 | ||
| 5 | import ( | |
| 6 | "errors" | |
| 7 | "fmt" | |
| 8 | "net" | |
| 9 | "os" | |
| 10 | "syscall" | |
| 11 | ) | |
| 12 | ||
| 13 | // checkPeer refuses a connection from any uid but the daemon's: git, | |
| 14 | // and so every hook, runs as the daemon's user. | |
| 15 | func checkPeer(conn net.Conn) error { | |
| 16 | uc, ok := conn.(*net.UnixConn) | |
| 17 | if !ok { | |
| 18 | return fmt.Errorf("not a unix socket connection") | |
| 19 | } | |
| 20 | raw, err := uc.SyscallConn() | |
| 21 | if err != nil { | |
| 22 | return err | |
| 23 | } | |
| 24 | var cred *syscall.Ucred | |
| 25 | var credErr error | |
| 26 | if err := raw.Control(func(fd uintptr) { | |
| 27 | cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |
| 28 | }); err != nil { | |
| 29 | return err | |
| 30 | } | |
| 31 | if credErr != nil { | |
| 32 | return credErr | |
| 33 | } | |
| 34 | if int(cred.Uid) != os.Getuid() { | |
| 35 | return errors.New("peer uid not permitted") | |
| 36 | } | |
| 37 | return nil | |
| 38 | } | |
internal/hookd/peercred_other.go added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | //go:build !linux | |
| 2 | ||
| 3 | package hookd | |
| 4 | ||
| 5 | import "net" | |
| 6 | ||
| 7 | // checkPeer reads peer credentials on Linux only; elsewhere the | |
| 8 | // socket's 0600 mode is the boundary. | |
| 9 | func checkPeer(net.Conn) error { return nil } | |
internal/hookd/socket_test.go added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | package hookd | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/config" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) { | |
| 14 | t.Helper() | |
| 15 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 16 | if err != nil { | |
| 17 | t.Fatal(err) | |
| 18 | } | |
| 19 | t.Cleanup(func() { st.Close() }) | |
| 20 | if err := st.MigrateUp(); err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if uid, err = st.CreateUser("alice", false); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | var cfg config.Config | |
| 30 | cfg.Server.Root = t.TempDir() | |
| 31 | stop, err := Serve(cfg, st) | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | t.Cleanup(func() { stop() }) | |
| 36 | return SocketPath(cfg.Server.Root), st, repoID, uid | |
| 37 | } | |
| 38 | ||
| 39 | func TestSocketIsOwnerOnly(t *testing.T) { | |
| 40 | sock, _, _, _ := serveSocket(t) | |
| 41 | fi, err := os.Stat(sock) | |
| 42 | if err != nil { | |
| 43 | t.Fatal(err) | |
| 44 | } | |
| 45 | if fi.Mode().Perm() != 0o600 { | |
| 46 | t.Fatalf("mode %v, want 0600", fi.Mode().Perm()) | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | // A request speaks for a receive-pack sshd started, and only for the | |
| 51 | // repository, account and scope that push was started with (#282). | |
| 52 | func TestHookRequestNeedsItsPushToken(t *testing.T) { | |
| 53 | sock, st, repoID, uid := serveSocket(t) | |
| 54 | req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"} | |
| 55 | ||
| 56 | resp, err := Ask(sock, req, nil) | |
| 57 | if err != nil { | |
| 58 | t.Fatal(err) | |
| 59 | } | |
| 60 | if resp.Allow || !strings.Contains(resp.Message, "not started by this server") { | |
| 61 | t.Fatalf("no token: %+v", resp) | |
| 62 | } | |
| 63 | ||
| 64 | token, err := st.CreatePushToken(repoID, uid, "full") | |
| 65 | if err != nil { | |
| 66 | t.Fatal(err) | |
| 67 | } | |
| 68 | req.Token = token | |
| 69 | if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow { | |
| 70 | t.Fatalf("with token: %+v, %v", resp, err) | |
| 71 | } | |
| 72 | ||
| 73 | other, err := st.CreateUser("mallory", false) | |
| 74 | if err != nil { | |
| 75 | t.Fatal(err) | |
| 76 | } | |
| 77 | forged := req | |
| 78 | forged.UserID = other | |
| 79 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | |
| 80 | t.Fatalf("token for another account: %+v, %v", resp, err) | |
| 81 | } | |
| 82 | ||
| 83 | otherRepo, err := st.CreateRepo("user", uid, "lib", "public") | |
| 84 | if err != nil { | |
| 85 | t.Fatal(err) | |
| 86 | } | |
| 87 | forged = req | |
| 88 | forged.RepoID = otherRepo | |
| 89 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | |
| 90 | t.Fatalf("token for another repository: %+v, %v", resp, err) | |
| 91 | } | |
| 92 | ||
| 93 | forged = req | |
| 94 | forged.Scope = "read" | |
| 95 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | |
| 96 | t.Fatalf("token for another scope: %+v, %v", resp, err) | |
| 97 | } | |
| 98 | ||
| 99 | if err := st.DeletePushToken(token); err != nil { | |
| 100 | t.Fatal(err) | |
| 101 | } | |
| 102 | if resp, err = Ask(sock, req, nil); err != nil || resp.Allow { | |
| 103 | t.Fatalf("finished push: %+v, %v", resp, err) | |
| 104 | } | |
| 105 | } | |