internal/sshd/refusal_test.go

v1.43.0
gitbay/internal/sshd/refusal_test.go history · blame · raw

415 lines · 13452 bytes

  1package sshd
  2
  3import (
  4	"bytes"
  5	"errors"
  6	"io"
  7	"os"
  8	"path/filepath"
  9	"strconv"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"gitbay.org/gitbay/internal/config"
 15	"gitbay.org/gitbay/internal/control"
 16	"gitbay.org/gitbay/internal/gitutil"
 17	"gitbay.org/gitbay/internal/packlimit"
 18	"gitbay.org/gitbay/internal/protocol"
 19	"gitbay.org/gitbay/internal/store"
 20)
 21
 22// execFixture: alice owns the public alice/app; bob has no grant on it.
 23func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
 24	t.Helper()
 25	st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
 26	if err != nil {
 27		t.Fatal(err)
 28	}
 29	t.Cleanup(func() { st.Close() })
 30	if err := st.MigrateUp(); err != nil {
 31		t.Fatal(err)
 32	}
 33	alice, err := st.CreateUser("alice", false)
 34	if err != nil {
 35		t.Fatal(err)
 36	}
 37	if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
 38		t.Fatal(err)
 39	}
 40	bobID, err := st.CreateUser("bob", false)
 41	if err != nil {
 42		t.Fatal(err)
 43	}
 44	bob, err := st.UserByID(bobID)
 45	if err != nil {
 46		t.Fatal(err)
 47	}
 48	cfg := config.Default()
 49	cfg.Server.Root = t.TempDir()
 50	return cfg, st, bob
 51}
 52
 53// A refused push leaves one row holding the target, the key and the exit
 54// code, whether runGit refused it or the account is not yet active.
 55func TestRefusedPushIsAudited(t *testing.T) {
 56	for _, pending := range []bool{false, true} {
 57		cfg, st, bob := execFixture(t)
 58		bob.Pending = pending
 59		key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
 60		var out, errOut bytes.Buffer
 61		code := Exec(cfg, st, nil, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
 62			strings.NewReader(""), &out, &errOut, nil, nil, nil)
 63		if code != protocol.ExitDenied {
 64			t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
 65		}
 66		got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
 67		if err != nil || len(got) != 1 || got[0].Actor != "bob" {
 68			t.Fatalf("pending %v: entries %+v, %v", pending, got, err)
 69		}
 70		want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}`
 71		if got[0].Data != want {
 72			t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want)
 73		}
 74	}
 75}
 76
 77func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
 78	cfg, st, bob := execFixture(t)
 79	packs := packlimit.New(1, 0, 0, time.Second)
 80	hold, err := packs.Acquire(nil, "ip:elsewhere")
 81	if err != nil {
 82		t.Fatal(err)
 83	}
 84	defer hold()
 85	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
 86	for _, service := range []string{"git-upload-pack", "git-upload-archive"} {
 87		var out, errOut bytes.Buffer
 88		code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, service+" alice/app",
 89			strings.NewReader(""), &out, &errOut, nil, nil, nil)
 90		if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
 91			t.Fatalf("%s: exit %d: %q", service, code, errOut.String())
 92		}
 93	}
 94}
 95
 96// A push takes no pack slot: it runs while every slot is held. A clone
 97// gives its slot back once git has exited.
 98func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
 99	cfg, st, _ := execFixture(t)
100	alice, err := st.UserByUsername("alice")
101	if err != nil {
102		t.Fatal(err)
103	}
104	if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
105		t.Fatal(err)
106	}
107	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
108	packs := packlimit.New(1, 0, 0, time.Second)
109
110	var out, errOut bytes.Buffer
111	if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
112		strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
113		t.Fatalf("clone: exit %d: %s", code, errOut.String())
114	}
115	hold, err := packs.Acquire(nil, "ip:elsewhere")
116	if err != nil {
117		t.Fatalf("slot not released after the clone: %v", err)
118	}
119	defer hold()
120
121	out.Reset()
122	errOut.Reset()
123	if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-receive-pack alice/app",
124		strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
125		t.Fatalf("push with slots full: exit %d: %s", code, errOut.String())
126	}
127}
128
129// cloneFixture adds an empty bare alice/app on disk and returns alice.
130func cloneFixture(t *testing.T) (config.Config, *store.Store, store.User) {
131	t.Helper()
132	cfg, st, _ := execFixture(t)
133	alice, err := st.UserByUsername("alice")
134	if err != nil {
135		t.Fatal(err)
136	}
137	if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
138		t.Fatal(err)
139	}
140	return cfg, st, alice
141}
142
143// silentStdin is a client that sends nothing and never hangs up. It is
144// an *os.File, so git reads it directly: git exits only when killed.
145func silentStdin(t *testing.T) *os.File {
146	t.Helper()
147	r, w, err := os.Pipe()
148	if err != nil {
149		t.Fatal(err)
150	}
151	t.Cleanup(func() { r.Close(); w.Close() })
152	return r
153}
154
155// killedClone runs a clone of alice/app with the given channels and
156// requires it to end, killed, within five seconds, with its slot free.
157func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan struct{}) {
158	t.Helper()
159	cfg, st, alice := cloneFixture(t)
160	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
161	packs := packlimit.New(1, 0, 0, time.Second)
162	codec := make(chan int, 1)
163	go func() {
164		codec <- Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
165			silentStdin(t), stdout, io.Discard, done, stopping, revoked)
166	}()
167	select {
168	case code := <-codec:
169		if code != protocol.ExitFailure {
170			t.Fatalf("exit %d, want the clone killed", code)
171		}
172	case <-time.After(5 * time.Second):
173		t.Fatal("clone still running")
174	}
175	hold, err := packs.Acquire(nil, "ip:elsewhere")
176	if err != nil {
177		t.Fatalf("slot not released after the kill: %v", err)
178	}
179	hold()
180}
181
182func closed() <-chan struct{} {
183	c := make(chan struct{})
184	close(c)
185	return c
186}
187
188func TestCloneKilledWhenClientLeaves(t *testing.T) {
189	killedClone(t, io.Discard, closed(), nil, nil)
190}
191
192// A revoked key ends a clone even during a restart.
193func TestCloneKilledWhenKeyRevoked(t *testing.T) {
194	killedClone(t, io.Discard, nil, closed(), closed())
195}
196
197// A client that stops reading is cut after packlimit.StallDeadline.
198func TestCloneKilledWhenClientStopsReading(t *testing.T) {
199	old := packlimit.StallDeadline
200	packlimit.StallDeadline = 200 * time.Millisecond
201	t.Cleanup(func() { packlimit.StallDeadline = old })
202	r, w := io.Pipe()
203	t.Cleanup(func() { r.Close() })
204	killedClone(t, w, nil, nil, nil)
205}
206
207// On a restart (done and stopping both closed) a running clone finishes.
208func TestCloneRunsOnDuringRestart(t *testing.T) {
209	cfg, st, alice := cloneFixture(t)
210	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
211	packs := packlimit.New(1, 0, 0, time.Second)
212	var errOut bytes.Buffer
213	if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
214		strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK {
215		t.Fatalf("exit %d: %s", code, errOut.String())
216	}
217}
218
219// A request the key may not make is refused before it reaches the
220// limiter: not found, never busy.
221func TestRefusedCloneStaysOffLimiter(t *testing.T) {
222	cfg, st, bob := execFixture(t)
223	alice, err := st.UserByUsername("alice")
224	if err != nil {
225		t.Fatal(err)
226	}
227	if _, err := st.CreateRepo("user", alice.ID, "secret", "private"); err != nil {
228		t.Fatal(err)
229	}
230	packs := packlimit.New(1, 0, 0, time.Second)
231	hold, err := packs.Acquire(nil, "ip:elsewhere")
232	if err != nil {
233		t.Fatal(err)
234	}
235	defer hold()
236	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
237	var out, errOut bytes.Buffer
238	code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, "git-upload-pack alice/secret",
239		strings.NewReader(""), &out, &errOut, nil, nil, nil)
240	if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") {
241		t.Fatalf("exit %d: %q", code, errOut.String())
242	}
243}
244
245// hungUpStdin is a client that sends nothing until hangUp, which ends
246// its stdin the way a closed channel does.
247func hungUpStdin(t *testing.T) (r *os.File, hangUp func()) {
248	t.Helper()
249	r, w, err := os.Pipe()
250	if err != nil {
251		t.Fatal(err)
252	}
253	t.Cleanup(func() { r.Close(); w.Close() })
254	return r, func() { w.Close() }
255}
256
257// queuedFor waits until principal has a push waiting on l: a probe that
258// gives up at once is then refused busy rather than queued.
259func queuedFor(t *testing.T, l *packlimit.Limiter, principal string) {
260	t.Helper()
261	deadline := time.Now().Add(5 * time.Second)
262	for time.Now().Before(deadline) {
263		if _, err := l.Acquire(closed(), principal); errors.Is(err, packlimit.ErrBusy) {
264			return
265		}
266		time.Sleep(10 * time.Millisecond)
267	}
268	t.Fatalf("no push queued for %s", principal)
269}
270
271// With push_per_principal 1 one account runs one push, queues a second
272// and is refused a third, while another principal still gets in. A
273// client hanging up frees its slot for the one queued behind it.
274func TestPushPerPrincipalCap(t *testing.T) {
275	cfg, st, alice := cloneFixture(t)
276	repo, err := st.RepoByPath("alice/app")
277	if err != nil {
278		t.Fatal(err)
279	}
280	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
281	pushes := packlimit.New(2, 1, 16, 10*time.Second)
282	push := func(k store.SSHKey, stdin io.Reader, errOut io.Writer) <-chan int {
283		codec := make(chan int, 1)
284		go func() {
285			codec <- Exec(cfg, st, nil, pushes, alice, k, control.Term{}, "git-receive-pack alice/app",
286				stdin, io.Discard, errOut, nil, nil, nil)
287		}()
288		return codec
289	}
290	exited := func(codec <-chan int, want int, what string) {
291		t.Helper()
292		select {
293		case code := <-codec:
294			if code != want {
295				t.Fatalf("%s: exit %d", what, code)
296			}
297		case <-time.After(5 * time.Second):
298			t.Fatalf("%s still running", what)
299		}
300	}
301	principal := "user:" + strconv.FormatInt(alice.ID, 10)
302
303	in1, hangUp1 := hungUpStdin(t)
304	first := push(key, in1, io.Discard)
305	// The first holds alice's one slot once a probe cannot take it.
306	deadline := time.Now().Add(5 * time.Second)
307	for {
308		r, err := pushes.Acquire(closed(), principal)
309		if err != nil {
310			break
311		}
312		r()
313		if time.Now().After(deadline) {
314			t.Fatal("first push never took a slot")
315		}
316		time.Sleep(10 * time.Millisecond)
317	}
318	in2, hangUp2 := hungUpStdin(t)
319	second := push(key, in2, io.Discard)
320	queuedFor(t, pushes, principal)
321
322	var errOut bytes.Buffer
323	if code := Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
324		strings.NewReader(""), io.Discard, &errOut, nil, nil, nil); code != protocol.ExitFailure ||
325		!strings.Contains(errOut.String(), "limit of concurrent pushes") {
326		t.Fatalf("third push: exit %d: %q", code, errOut.String())
327	}
328
329	// A deploy key on the same account is its own principal: it takes
330	// the second global slot while alice's push waits.
331	deploy := store.SSHKey{ID: 2, Scope: "deploy:" + strconv.FormatInt(repo.ID, 10) + ":rw", Fingerprint: "SHA256:deploy"}
332	exited(push(deploy, strings.NewReader("0000"), io.Discard), protocol.ExitOK, "deploy key push")
333
334	// receive-pack fails a client that hangs up before sending anything.
335	hangUp1()
336	exited(first, protocol.ExitFailure, "first push")
337	hangUp2()
338	exited(second, protocol.ExitFailure, "second push")
339	for _, p := range []string{"a", "b"} {
340		r, err := pushes.Acquire(nil, p)
341		if err != nil {
342			t.Fatalf("slot not released: %v", err)
343		}
344		defer r()
345	}
346}
347
348// A revoked key kills a push waiting on its client, and the slot it
349// held comes back.
350func TestPushKilledWhenKeyRevokedReleasesSlot(t *testing.T) {
351	cfg, st, alice := cloneFixture(t)
352	key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
353	pushes := packlimit.New(1, 1, 0, time.Second)
354	revoked := make(chan struct{})
355	codec := make(chan int, 1)
356	go func() {
357		codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
358			silentStdin(t), io.Discard, io.Discard, nil, nil, revoked)
359	}()
360	slotTaken(t, pushes)
361	close(revoked)
362	pushEnded(t, codec, pushes, 5*time.Second)
363}
364
365// slotTaken waits until l's one slot is held.
366func slotTaken(t *testing.T, l *packlimit.Limiter) {
367	t.Helper()
368	deadline := time.Now().Add(5 * time.Second)
369	for time.Now().Before(deadline) {
370		r, err := l.Acquire(closed(), "probe")
371		if err != nil {
372			return
373		}
374		r()
375		time.Sleep(10 * time.Millisecond)
376	}
377	t.Fatal("the push never took the slot")
378}
379
380// pushEnded requires a push to end, killed, within within, with l's
381// slot free again.
382func pushEnded(t *testing.T, codec <-chan int, l *packlimit.Limiter, within time.Duration) {
383	t.Helper()
384	select {
385	case code := <-codec:
386		if code != protocol.ExitFailure {
387			t.Fatalf("exit %d, want the push killed", code)
388		}
389	case <-time.After(within):
390		t.Fatal("push still running")
391	}
392	r, err := l.Acquire(nil, "elsewhere")
393	if err != nil {
394		t.Fatalf("slot not released after the kill: %v", err)
395	}
396	r()
397}
398
399// A push the key may not make is refused before it reaches the limiter.
400func TestRefusedPushStaysOffLimiter(t *testing.T) {
401	cfg, st, bob := execFixture(t)
402	pushes := packlimit.New(1, 0, 0, time.Second)
403	hold, err := pushes.Acquire(nil, "elsewhere")
404	if err != nil {
405		t.Fatal(err)
406	}
407	defer hold()
408	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
409	var errOut bytes.Buffer
410	code := Exec(cfg, st, nil, pushes, bob, key, control.Term{}, "git-receive-pack alice/app",
411		strings.NewReader(""), io.Discard, &errOut, nil, nil, nil)
412	if code != protocol.ExitDenied || strings.Contains(errOut.String(), "busy") {
413		t.Fatalf("exit %d: %q", code, errOut.String())
414	}
415}