internal/sshd/refusal_test.go

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

241 lines · 7807 bytes

  1package sshd
  2
  3import (
  4	"bytes"
  5	"io"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10	"time"
 11
 12	"gitbay.org/gitbay/internal/config"
 13	"gitbay.org/gitbay/internal/control"
 14	"gitbay.org/gitbay/internal/gitutil"
 15	"gitbay.org/gitbay/internal/packlimit"
 16	"gitbay.org/gitbay/internal/protocol"
 17	"gitbay.org/gitbay/internal/store"
 18)
 19
 20// execFixture: alice owns the public alice/app; bob has no grant on it.
 21func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
 22	t.Helper()
 23	st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27	t.Cleanup(func() { st.Close() })
 28	if err := st.MigrateUp(); err != nil {
 29		t.Fatal(err)
 30	}
 31	alice, err := st.CreateUser("alice", false)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
 36		t.Fatal(err)
 37	}
 38	bobID, err := st.CreateUser("bob", false)
 39	if err != nil {
 40		t.Fatal(err)
 41	}
 42	bob, err := st.UserByID(bobID)
 43	if err != nil {
 44		t.Fatal(err)
 45	}
 46	cfg := config.Default()
 47	cfg.Server.Root = t.TempDir()
 48	return cfg, st, bob
 49}
 50
 51// A refused push leaves one row holding the target, the key and the exit
 52// code, whether runGit refused it or the account is not yet active.
 53func TestRefusedPushIsAudited(t *testing.T) {
 54	for _, pending := range []bool{false, true} {
 55		cfg, st, bob := execFixture(t)
 56		bob.Pending = pending
 57		key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
 58		var out, errOut bytes.Buffer
 59		code := Exec(cfg, st, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
 60			strings.NewReader(""), &out, &errOut, nil, nil, nil)
 61		if code != protocol.ExitDenied {
 62			t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
 63		}
 64		got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
 65		if err != nil || len(got) != 1 || got[0].Actor != "bob" {
 66			t.Fatalf("pending %v: entries %+v, %v", pending, got, err)
 67		}
 68		want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}`
 69		if got[0].Data != want {
 70			t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want)
 71		}
 72	}
 73}
 74
 75func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
 76	cfg, st, bob := execFixture(t)
 77	packs := packlimit.New(1, 0, 0, time.Second)
 78	hold, err := packs.Acquire(nil, "ip:elsewhere")
 79	if err != nil {
 80		t.Fatal(err)
 81	}
 82	defer hold()
 83	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
 84	for _, service := range []string{"git-upload-pack", "git-upload-archive"} {
 85		var out, errOut bytes.Buffer
 86		code := Exec(cfg, st, packs, bob, key, control.Term{}, service+" alice/app",
 87			strings.NewReader(""), &out, &errOut, nil, nil, nil)
 88		if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
 89			t.Fatalf("%s: exit %d: %q", service, code, errOut.String())
 90		}
 91	}
 92}
 93
 94// A push takes no pack slot: it runs while every slot is held. A clone
 95// gives its slot back once git has exited.
 96func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
 97	cfg, st, _ := execFixture(t)
 98	alice, err := st.UserByUsername("alice")
 99	if err != nil {
100		t.Fatal(err)
101	}
102	if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
103		t.Fatal(err)
104	}
105	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
106	packs := packlimit.New(1, 0, 0, time.Second)
107
108	var out, errOut bytes.Buffer
109	if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
110		strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
111		t.Fatalf("clone: exit %d: %s", code, errOut.String())
112	}
113	hold, err := packs.Acquire(nil, "ip:elsewhere")
114	if err != nil {
115		t.Fatalf("slot not released after the clone: %v", err)
116	}
117	defer hold()
118
119	out.Reset()
120	errOut.Reset()
121	if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-receive-pack alice/app",
122		strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
123		t.Fatalf("push with slots full: exit %d: %s", code, errOut.String())
124	}
125}
126
127// cloneFixture adds an empty bare alice/app on disk and returns alice.
128func cloneFixture(t *testing.T) (config.Config, *store.Store, store.User) {
129	t.Helper()
130	cfg, st, _ := execFixture(t)
131	alice, err := st.UserByUsername("alice")
132	if err != nil {
133		t.Fatal(err)
134	}
135	if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
136		t.Fatal(err)
137	}
138	return cfg, st, alice
139}
140
141// silentStdin is a client that sends nothing and never hangs up. It is
142// an *os.File, so git reads it directly: git exits only when killed.
143func silentStdin(t *testing.T) *os.File {
144	t.Helper()
145	r, w, err := os.Pipe()
146	if err != nil {
147		t.Fatal(err)
148	}
149	t.Cleanup(func() { r.Close(); w.Close() })
150	return r
151}
152
153// killedClone runs a clone of alice/app with the given channels and
154// requires it to end, killed, within five seconds, with its slot free.
155func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan struct{}) {
156	t.Helper()
157	cfg, st, alice := cloneFixture(t)
158	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
159	packs := packlimit.New(1, 0, 0, time.Second)
160	codec := make(chan int, 1)
161	go func() {
162		codec <- Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
163			silentStdin(t), stdout, io.Discard, done, stopping, revoked)
164	}()
165	select {
166	case code := <-codec:
167		if code != protocol.ExitFailure {
168			t.Fatalf("exit %d, want the clone killed", code)
169		}
170	case <-time.After(5 * time.Second):
171		t.Fatal("clone still running")
172	}
173	hold, err := packs.Acquire(nil, "ip:elsewhere")
174	if err != nil {
175		t.Fatalf("slot not released after the kill: %v", err)
176	}
177	hold()
178}
179
180func closed() <-chan struct{} {
181	c := make(chan struct{})
182	close(c)
183	return c
184}
185
186func TestCloneKilledWhenClientLeaves(t *testing.T) {
187	killedClone(t, io.Discard, closed(), nil, nil)
188}
189
190// A revoked key ends a clone even during a restart.
191func TestCloneKilledWhenKeyRevoked(t *testing.T) {
192	killedClone(t, io.Discard, nil, closed(), closed())
193}
194
195// A client that stops reading is cut after packlimit.StallDeadline.
196func TestCloneKilledWhenClientStopsReading(t *testing.T) {
197	old := packlimit.StallDeadline
198	packlimit.StallDeadline = 200 * time.Millisecond
199	t.Cleanup(func() { packlimit.StallDeadline = old })
200	r, w := io.Pipe()
201	t.Cleanup(func() { r.Close() })
202	killedClone(t, w, nil, nil, nil)
203}
204
205// On a restart (done and stopping both closed) a running clone finishes.
206func TestCloneRunsOnDuringRestart(t *testing.T) {
207	cfg, st, alice := cloneFixture(t)
208	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
209	packs := packlimit.New(1, 0, 0, time.Second)
210	var errOut bytes.Buffer
211	if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
212		strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK {
213		t.Fatalf("exit %d: %s", code, errOut.String())
214	}
215}
216
217// A request the key may not make is refused before it reaches the
218// limiter: not found, never busy.
219func TestRefusedCloneStaysOffLimiter(t *testing.T) {
220	cfg, st, bob := execFixture(t)
221	alice, err := st.UserByUsername("alice")
222	if err != nil {
223		t.Fatal(err)
224	}
225	if _, err := st.CreateRepo("user", alice.ID, "secret", "private"); err != nil {
226		t.Fatal(err)
227	}
228	packs := packlimit.New(1, 0, 0, time.Second)
229	hold, err := packs.Acquire(nil, "ip:elsewhere")
230	if err != nil {
231		t.Fatal(err)
232	}
233	defer hold()
234	key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
235	var out, errOut bytes.Buffer
236	code := Exec(cfg, st, packs, bob, key, control.Term{}, "git-upload-pack alice/secret",
237		strings.NewReader(""), &out, &errOut, nil, nil, nil)
238	if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") {
239		t.Fatalf("exit %d: %q", code, errOut.String())
240	}
241}