Commit 135af80d08
Verified · cmc
Layout: unified · split
internal/hookd/hookd.go +36
| @@ -36,6 +36,10 @@ const ( | |||
| 36 | EnvRepoID = "GITBAY_REPO_ID" | 36 | EnvRepoID = "GITBAY_REPO_ID" |
| 37 | EnvUserID = "GITBAY_USER_ID" | 37 | EnvUserID = "GITBAY_USER_ID" |
| 38 | EnvScope = "GITBAY_KEY_SCOPE" | 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 | type Request struct { | 45 | type Request struct { |
| @@ -46,6 +50,7 @@ type Request struct { | |||
| 46 | // the key belongs to, and a deploy key grants nothing outside its | 50 | // the key belongs to, and a deploy key grants nothing outside its |
| 47 | // binding, so anything acting on another repository needs this too. | 51 | // binding, so anything acting on another repository needs this too. |
| 48 | Scope string `json:"scope"` | 52 | Scope string `json:"scope"` |
| 53 | Token string `json:"token"` | ||
| 49 | Updates []policy.RefUpdate `json:"updates"` | 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 | if err != nil { | 99 | if err != nil { |
| 95 | return nil, err | 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 | s := &Server{cfg: cfg, st: st} | 108 | s := &Server{cfg: cfg, st: st} |
| 98 | go func() { | 109 | go func() { |
| 99 | for { | 110 | for { |
| @@ -111,11 +122,20 @@ func (s *Server) handle(conn net.Conn) { | |||
| 111 | defer conn.Close() | 122 | defer conn.Close() |
| 112 | dec := json.NewDecoder(conn) | 123 | dec := json.NewDecoder(conn) |
| 113 | enc := json.NewEncoder(conn) | 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 | var req Request | 130 | var req Request |
| 115 | if err := dec.Decode(&req); err != nil { | 131 | if err := dec.Decode(&req); err != nil { |
| 116 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) | 132 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) |
| 117 | return | 133 | return |
| 118 | } | 134 | } |
| 135 | if msg := s.authorize(req); msg != "" { | ||
| 136 | enc.Encode(Response{Allow: false, Message: msg}) | ||
| 137 | return | ||
| 138 | } | ||
| 119 | switch req.Hook { | 139 | switch req.Hook { |
| 120 | case "pre-receive": | 140 | case "pre-receive": |
| 121 | s.preReceive(req, dec, enc) | 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 | func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | 166 | func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { |
| 131 | repo, err := s.st.RepoByID(req.RepoID) | 167 | repo, err := s.st.RepoByID(req.RepoID) |
| 132 | if err != nil { | 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 | } | ||