internal/hookd/socket_test.go
231 lines · 7371 bytes
1package hookd
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) {
20 t.Helper()
21 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
22 if err != nil {
23 t.Fatal(err)
24 }
25 t.Cleanup(func() { st.Close() })
26 if err := st.MigrateUp(); err != nil {
27 t.Fatal(err)
28 }
29 if uid, err = st.CreateUser("alice", false); err != nil {
30 t.Fatal(err)
31 }
32 if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil {
33 t.Fatal(err)
34 }
35 var cfg config.Config
36 cfg.Server.Root = t.TempDir()
37 stop, err := Serve(cfg, st)
38 if err != nil {
39 t.Fatal(err)
40 }
41 t.Cleanup(func() { stop() })
42 return SocketPath(cfg.Server.Root), st, repoID, uid
43}
44
45func TestSocketIsOwnerOnly(t *testing.T) {
46 sock, _, _, _ := serveSocket(t)
47 fi, err := os.Stat(sock)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if fi.Mode().Perm() != 0o600 {
52 t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
53 }
54}
55
56// A request speaks for a receive-pack sshd started, and only for the
57// repository, account and scope that push was started with (#282).
58func TestHookRequestNeedsItsPushToken(t *testing.T) {
59 sock, st, repoID, uid := serveSocket(t)
60 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"}
61
62 resp, err := Ask(sock, req, nil)
63 if err != nil {
64 t.Fatal(err)
65 }
66 if resp.Allow || !strings.Contains(resp.Message, "not started by this server") {
67 t.Fatalf("no token: %+v", resp)
68 }
69
70 token, err := st.CreatePushToken(repoID, uid, "full")
71 if err != nil {
72 t.Fatal(err)
73 }
74 req.Token = token
75 if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow {
76 t.Fatalf("with token: %+v, %v", resp, err)
77 }
78
79 other, err := st.CreateUser("mallory", false)
80 if err != nil {
81 t.Fatal(err)
82 }
83 forged := req
84 forged.UserID = other
85 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
86 t.Fatalf("token for another account: %+v, %v", resp, err)
87 }
88
89 otherRepo, err := st.CreateRepo("user", uid, "lib", "public")
90 if err != nil {
91 t.Fatal(err)
92 }
93 forged = req
94 forged.RepoID = otherRepo
95 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
96 t.Fatalf("token for another repository: %+v, %v", resp, err)
97 }
98
99 forged = req
100 forged.Scope = "read"
101 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
102 t.Fatalf("token for another scope: %+v, %v", resp, err)
103 }
104
105 if err := st.DeletePushToken(token); err != nil {
106 t.Fatal(err)
107 }
108 if resp, err = Ask(sock, req, nil); err != nil || resp.Allow {
109 t.Fatalf("finished push: %+v, %v", resp, err)
110 }
111}
112
113func refusedRows(t *testing.T, st *store.Store, action string) []store.AuditEntry {
114 t.Helper()
115 rows, err := st.AuditEntries(store.AuditFilter{ActionPrefix: action, Limit: 10})
116 if err != nil {
117 t.Fatal(err)
118 }
119 return rows
120}
121
122// Refused hook requests and refused pushes are audited; the token never
123// lands in a row (#275).
124func TestHookRefusalsAreAudited(t *testing.T) {
125 sock, st, repoID, uid := serveSocket(t)
126
127 forged := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: "not-a-live-token"}
128 if resp, err := Ask(sock, forged, nil); err != nil || resp.Allow {
129 t.Fatalf("forged: %+v, %v", resp, err)
130 }
131 rows := refusedRows(t, st, "refused hook")
132 if len(rows) != 1 || rows[0].Actor != "" || strings.Contains(rows[0].Data, forged.Token) ||
133 !strings.Contains(rows[0].Data, "not started by this server") || !strings.Contains(rows[0].Data, `"hook":"pre-receive"`) {
134 t.Fatalf("refused hook rows: %+v", rows)
135 }
136
137 token, err := st.CreatePushToken(repoID, uid, "full")
138 if err != nil {
139 t.Fatal(err)
140 }
141 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: token,
142 Updates: []policy.RefUpdate{{Ref: "refs/merge-requests/1/head", Old: zeroSHA40, New: strings.Repeat("a", 40)}}}
143 if resp, err := Ask(sock, req, nil); err != nil || resp.Allow {
144 t.Fatalf("push to a server-owned ref: %+v, %v", resp, err)
145 }
146 rows = refusedRows(t, st, "refused push")
147 if len(rows) != 1 || rows[0].Actor != "alice" || strings.Contains(rows[0].Data, token) ||
148 !strings.Contains(rows[0].Data, "alice/app") || !strings.Contains(rows[0].Data, "refs/merge-requests/1/head") {
149 t.Fatalf("refused push rows: %+v", rows)
150 }
151}
152
153// A connection from another uid is audited with no actor.
154func TestPeerRefusalIsAudited(t *testing.T) {
155 old := peerCheck
156 peerCheck = func(net.Conn) error { return errors.New("peer uid not permitted") }
157 t.Cleanup(func() { peerCheck = old })
158 sock, st, repoID, uid := serveSocket(t)
159 // The server answers and closes without reading the request, so the
160 // client may see the refusal or a broken pipe; either way it is not
161 // allowed, and the row is written before the connection closes.
162 if resp, err := Ask(sock, Request{Hook: "pre-receive", RepoID: repoID, UserID: uid}, nil); err == nil && resp.Allow {
163 t.Fatalf("refused peer was allowed: %+v", resp)
164 }
165 rows := refusedRows(t, st, "refused hook")
166 if len(rows) != 1 || rows[0].Actor != "" || !strings.Contains(rows[0].Data, "peer uid not permitted") {
167 t.Fatalf("refused hook rows: %+v", rows)
168 }
169}
170
171// A refused push of many refs records the first auditedRefs names and
172// a count of the rest.
173func TestRefusedPushCapsRefs(t *testing.T) {
174 sock, st, repoID, uid := serveSocket(t)
175 token, err := st.CreatePushToken(repoID, uid, "full")
176 if err != nil {
177 t.Fatal(err)
178 }
179 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: token}
180 for i := range 500 {
181 req.Updates = append(req.Updates, policy.RefUpdate{Ref: fmt.Sprintf("refs/merge-requests/%d/head", i),
182 Old: zeroSHA40, New: strings.Repeat("a", 40)})
183 }
184 if resp, err := Ask(sock, req, nil); err != nil || resp.Allow {
185 t.Fatalf("push: %+v, %v", resp, err)
186 }
187 rows := refusedRows(t, st, "refused push")
188 if len(rows) != 1 {
189 t.Fatalf("rows: %+v", rows)
190 }
191 var data struct {
192 Refs []string `json:"refs"`
193 MoreRefs int `json:"more_refs"`
194 }
195 if err := json.Unmarshal([]byte(rows[0].Data), &data); err != nil {
196 t.Fatal(err)
197 }
198 if len(data.Refs) != auditedRefs || data.MoreRefs != 500-auditedRefs || data.Refs[0] != "refs/merge-requests/0/head" {
199 t.Fatalf("refs %d, more %d", len(data.Refs), data.MoreRefs)
200 }
201}
202
203// A push's pre-receive reaching hookd closes the channel sshd waits on;
204// a request that fails the token check does not.
205func TestPreReceiveSignalsItsPush(t *testing.T) {
206 sock, st, repoID, uid := serveSocket(t)
207 token, err := st.CreatePushToken(repoID, uid, "full")
208 if err != nil {
209 t.Fatal(err)
210 }
211 started, forget := AwaitPreReceive(token)
212 defer forget()
213 forged := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "read", Token: token}
214 if resp, err := Ask(sock, forged, nil); err != nil || resp.Allow {
215 t.Fatalf("forged: %+v, %v", resp, err)
216 }
217 select {
218 case <-started:
219 t.Fatal("a refused request signalled the push")
220 default:
221 }
222 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: token}
223 if resp, err := Ask(sock, req, nil); err != nil || !resp.Allow {
224 t.Fatalf("pre-receive: %+v, %v", resp, err)
225 }
226 select {
227 case <-started:
228 case <-time.After(5 * time.Second):
229 t.Fatal("pre-receive did not signal the push")
230 }
231}