Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 24m ago 32290114
history blame raw 14.2 KiB
1package ssh
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/pem"
7 "fmt"
8 "io"
9 "log/slog"
10 "net"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "runtime/debug"
15 "strings"
16 "testing"
17 "time"
18
19 xssh "golang.org/x/crypto/ssh"
20
21 "rodent.software/vole/ci/logs"
22 "rodent.software/vole/ci/runs"
23 "rodent.software/vole/core/lfs"
24 "rodent.software/vole/forge/access"
25 "rodent.software/vole/forge/identity"
26 "rodent.software/vole/forge/repo"
27)
28
29var binPath string
30
31// binaryBuildArgs returns the tags this test binary was built with, so the
32// binary it starts is built the same way; `go test -tags sqlite_fts5` does not
33// pass its tags on to a `go build` the test runs.
34func binaryBuildArgs() []string {
35 info, ok := debug.ReadBuildInfo()
36 if !ok {
37 return nil
38 }
39 for _, setting := range info.Settings {
40 if setting.Key == "-tags" && setting.Value != "" {
41 return []string{"-tags", setting.Value}
42 }
43 }
44 return nil
45}
46
47// TestMain builds the real binary: installed hooks exec it, so a stub would let
48// every policy test pass for the wrong reason.
49func TestMain(m *testing.M) {
50 dir, err := os.MkdirTemp("", "vole-ssh-bin-")
51 if err != nil {
52 fmt.Fprintln(os.Stderr, err)
53 os.Exit(1)
54 }
55 binPath = filepath.Join(dir, "vole")
56 args := append([]string{"build"}, binaryBuildArgs()...)
57 args = append(args, "-o", binPath, "rodent.software/vole/cmd/vole")
58 build := exec.Command("go", args...)
59 build.Stdout, build.Stderr = os.Stdout, os.Stderr
60 if err := build.Run(); err != nil {
61 fmt.Fprintln(os.Stderr, "build vole:", err)
62 os.Exit(1)
63 }
64 code := m.Run()
65 os.RemoveAll(dir)
66 os.Exit(code)
67}
68
69type harness struct {
70 t *testing.T
71 dir string
72 addr string
73 ident *identity.Store
74 repos *repo.Store
75 srv *Server
76 keyPath string // private key file for git
77 pubLine string // authorized_keys line for the writer
78 roKey string // private key file for the read-only user
79 repoName string
80 runs *runs.Store // the CI queue the runner verb serves
81 logs *logs.Store // the attempt logs the run verbs serve
82 objects *lfs.Store // where a run's artifacts are checked for
83 writerID string // the account id the writer's key belongs to
84}
85
86func newHarness(t *testing.T) *harness { return newHarnessWith(t, nil) }
87
88// newHarnessWith lets a test set the server's resource bounds before it starts,
89// which is how the handshake, connection and process caps get exercised at a
90// size a test can reach.
91func newHarnessWith(t *testing.T, tweak func(*Config)) *harness {
92 t.Helper()
93 dir := t.TempDir()
94 usersDB := filepath.Join(dir, "users.db")
95 if err := identity.Init(usersDB); err != nil {
96 t.Fatal(err)
97 }
98 ident, err := identity.Open(usersDB)
99 if err != nil {
100 t.Fatal(err)
101 }
102 ident.AuditPath = filepath.Join(dir, "audit.log")
103
104 writer, writerPub := keypair(t, filepath.Join(dir, "writer"))
105 reader, readerPub := keypair(t, filepath.Join(dir, "reader"))
106
107 var writerID string
108 for _, u := range []struct{ name, pub string }{
109 {"writer", writerPub},
110 {"reader", readerPub},
111 } {
112 acct, err := ident.Create(u.name, u.pub, "test")
113 if err != nil {
114 t.Fatal(err)
115 }
116 if u.name == "writer" {
117 writerID = acct.Name
118 }
119 }
120
121 acls, err := access.Open(usersDB)
122 if err != nil {
123 t.Fatal(err)
124 }
125 acls.AuditPath = filepath.Join(dir, "audit.log")
126 t.Cleanup(func() { acls.Close() })
127 rs := repo.New(filepath.Join(dir, "repos"), binPath, acls)
128 rs.MaxInputBytes = 1 << 20
129 m, err := rs.Create("demo", writerID, "")
130 if err != nil {
131 t.Fatal(err)
132 }
133 a, err := m.ACL()
134 if err != nil {
135 t.Fatal(err)
136 }
137 if err := a.SetEntry("reader", "read"); err != nil {
138 t.Fatal(err)
139 }
140 if err := m.SetACL(a, "test"); err != nil {
141 t.Fatal(err)
142 }
143
144 // The runner verb needs a queue and an object store: a finished attempt names a log
145 // and its artifacts, and those claims are checked against the store.
146 queue, err := runs.Open(filepath.Join(dir, "runs.db"))
147 if err != nil {
148 t.Fatal(err)
149 }
150 t.Cleanup(func() { queue.Close() })
151 objects := lfs.New(filepath.Join(dir, "objects"), 1<<20)
152 logStore := logs.New(filepath.Join(dir, "logs"))
153
154 log := slog.New(slog.NewTextHandler(io.Discard, nil))
155 cfg := Config{
156 Identity: ident,
157 Repos: rs,
158 Runs: queue,
159 Logs: logStore,
160 LFS: objects,
161 Log: log,
162 HostKeyPath: filepath.Join(dir, "host_key"),
163 AuditPath: filepath.Join(dir, "audit.log"),
164 }
165 if tweak != nil {
166 tweak(&cfg)
167 }
168 srv, err := New(cfg)
169 if err != nil {
170 t.Fatal(err)
171 }
172 ln, err := net.Listen("tcp", "127.0.0.1:0")
173 if err != nil {
174 t.Fatal(err)
175 }
176 go srv.Serve(ln)
177 t.Cleanup(func() { srv.Close() })
178
179 return &harness{
180 t: t, dir: dir, addr: ln.Addr().String(), ident: ident, repos: rs,
181 srv: srv, keyPath: writer, roKey: reader, repoName: "demo",
182 runs: queue, logs: logStore, objects: objects, writerID: writerID,
183 }
184}
185
186// keypair writes an OpenSSH private key and returns its path plus the public
187// key in authorized_keys form.
188func keypair(t *testing.T, path string) (string, string) {
189 t.Helper()
190 pub, priv, err := ed25519.GenerateKey(rand.Reader)
191 if err != nil {
192 t.Fatal(err)
193 }
194 blk, err := xssh.MarshalPrivateKey(priv, "test")
195 if err != nil {
196 t.Fatal(err)
197 }
198 if err := os.WriteFile(path, pem.EncodeToMemory(blk), 0o600); err != nil {
199 t.Fatal(err)
200 }
201 sshPub, err := xssh.NewPublicKey(pub)
202 if err != nil {
203 t.Fatal(err)
204 }
205 line := strings.TrimSpace(string(xssh.MarshalAuthorizedKey(sshPub))) + " test@vole\n"
206 if err := os.WriteFile(path+".pub", []byte(line), 0o644); err != nil {
207 t.Fatal(err)
208 }
209 return path, strings.TrimSpace(line)
210}
211
212func (h *harness) url() string {
213 return "ssh://git@" + h.addr + "/" + h.repoName + ".git"
214}
215
216func (h *harness) git(dir, key string, args ...string) (string, string, error) {
217 h.t.Helper()
218 cmd := exec.Command("git", args...)
219 cmd.Dir = dir
220 cmd.Env = append(os.Environ(),
221 "GIT_TERMINAL_PROMPT=0",
222 "GIT_CONFIG_GLOBAL=/dev/null",
223 "GIT_CONFIG_SYSTEM=/dev/null",
224 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
225 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
226 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
227 )
228 var out, errb strings.Builder
229 cmd.Stdout, cmd.Stderr = &out, &errb
230 err := cmd.Run()
231 return out.String(), errb.String(), err
232}
233
234func workRepo(t *testing.T) string {
235 t.Helper()
236 dir := t.TempDir()
237 for _, args := range [][]string{
238 {"init", "-q", "-b", "main"},
239 } {
240 cmd := exec.Command("git", args...)
241 cmd.Dir = dir
242 if out, err := cmd.CombinedOutput(); err != nil {
243 t.Fatalf("%v: %s", err, out)
244 }
245 }
246 if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# hi\n"), 0o644); err != nil {
247 t.Fatal(err)
248 }
249 env := append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
250 for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "init"}} {
251 cmd := exec.Command("git", args...)
252 cmd.Dir = dir
253 cmd.Env = env
254 if out, err := cmd.CombinedOutput(); err != nil {
255 t.Fatalf("%v: %s", err, out)
256 }
257 }
258 return dir
259}
260
261func (h *harness) serverRef() string {
262 out, _, err := h.git(filepath.Join(h.dir, "repos", "demo.git"), h.keyPath,
263 "rev-parse", "--verify", "--quiet", "refs/heads/main")
264 if err != nil {
265 return ""
266 }
267 return strings.TrimSpace(out)
268}
269
270func TestPushAndCloneOverSSH(t *testing.T) {
271 t.Parallel()
272 h := newHarness(t)
273 work := workRepo(t)
274
275 if _, errs, err := h.git(work, h.keyPath, "push", h.url(), "main:refs/heads/main"); err != nil {
276 t.Fatalf("push failed: %v\n%s", err, errs)
277 }
278 if h.serverRef() == "" {
279 t.Fatal("push landed no ref")
280 }
281
282 dst := filepath.Join(t.TempDir(), "clone")
283 if _, errs, err := h.git("", h.roKey, "clone", "-q", h.url(), dst); err != nil {
284 t.Fatalf("read-only clone failed: %v\n%s", err, errs)
285 }
286 got, err := os.ReadFile(filepath.Join(dst, "readme.md"))
287 if err != nil {
288 t.Fatal(err)
289 }
290 if string(got) != "# hi\n" {
291 t.Fatalf("cloned content = %q", got)
292 }
293}
294
295// TestInstanceAdminCanCloneWithoutACLEntry proves the read gate still applies to
296// ordinary accounts, and that instance administration is the one overlay: they
297// can clone a repository whose rules they can already rewrite.
298func TestInstanceAdminCanCloneWithoutACLEntry(t *testing.T) {
299 t.Parallel()
300 h := newHarness(t)
301 work := workRepo(t)
302 if _, errs, err := h.git(work, h.keyPath, "push", h.url(), "main:refs/heads/main"); err != nil {
303 t.Fatalf("push: %v\n%s", err, errs)
304 }
305
306 opKey, opPub := keypair(t, filepath.Join(h.dir, "operator"))
307 if _, err := h.ident.Create("operator", opPub, "test"); err != nil {
308 t.Fatal(err)
309 }
310
311 if _, _, err := h.git("", opKey, "clone", "-q", h.url(), filepath.Join(t.TempDir(), "denied")); err == nil {
312 t.Fatal("an account with no role cloned a private repository")
313 }
314
315 if _, err := h.ident.SetAdmin("operator", true, "test"); err != nil {
316 t.Fatal(err)
317 }
318 dst := filepath.Join(t.TempDir(), "allowed")
319 if _, errs, err := h.git("", opKey, "clone", "-q", h.url(), dst); err != nil {
320 t.Fatalf("instance admin clone: %v\n%s", err, errs)
321 }
322 got, err := os.ReadFile(filepath.Join(dst, "readme.md"))
323 if err != nil {
324 t.Fatal(err)
325 }
326 if string(got) != "# hi\n" {
327 t.Fatalf("cloned content = %q", got)
328 }
329}
330
331// TestReadOnlyUserCannotPush proves the hook sees the SSH identity: the
332// rejection has to name the user, not just fail.
333func TestReadOnlyUserCannotPush(t *testing.T) {
334 t.Parallel()
335 h := newHarness(t)
336 work := workRepo(t)
337 if _, errs, err := h.git(work, h.keyPath, "push", h.url(), "main:refs/heads/main"); err != nil {
338 t.Fatalf("writer push: %s", errs)
339 }
340 before := h.serverRef()
341
342 if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("# changed\n"), 0o644); err != nil {
343 t.Fatal(err)
344 }
345 cmd := exec.Command("git", "commit", "-aqm", "change")
346 cmd.Dir = work
347 cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
348 if out, err := cmd.CombinedOutput(); err != nil {
349 t.Fatalf("%v: %s", err, out)
350 }
351
352 _, errs, err := h.git(work, h.roKey, "push", h.url(), "main:refs/heads/main")
353 if err == nil {
354 t.Fatal("read-only user pushed successfully")
355 }
356 if !strings.Contains(errs, "does not have maintain or contribute access") || !strings.Contains(errs, "reader") {
357 t.Fatalf("rejection did not name the SSH identity:\n%s", errs)
358 }
359 if after := h.serverRef(); after != before {
360 t.Fatalf("ref moved on a rejected push: %s -> %s", before, after)
361 }
362}
363
364// TestExecWhitelistRefusesShell is the security test for this githttp. A
365// session that can exec arbitrary commands is a remote shell.
366func TestExecWhitelistRefusesShell(t *testing.T) {
367 t.Parallel()
368 h := newHarness(t)
369 client := h.dial(t, h.keyPath)
370
371 for _, command := range []string{
372 "sh -c 'echo pwned'",
373 "cat /etc/passwd",
374 "/bin/sh",
375 "git-upload-pack /demo.git; cat /etc/passwd",
376 "git-receive-pack",
377 "git-upload-pack '/demo.git' extra",
378 } {
379 sess, err := client.NewSession()
380 if err != nil {
381 t.Fatalf("new session: %v", err)
382 }
383 out, err := sess.CombinedOutput(command)
384 sess.Close()
385 if err == nil {
386 t.Fatalf("command %q succeeded, want refusal (output: %s)", command, out)
387 }
388 if strings.Contains(string(out), "pwned") || strings.Contains(string(out), "root:") {
389 t.Fatalf("command %q executed: %s", command, out)
390 }
391 }
392}
393
394func TestShellRequestRefused(t *testing.T) {
395 t.Parallel()
396 h := newHarness(t)
397 client := h.dial(t, h.keyPath)
398 sess, err := client.NewSession()
399 if err != nil {
400 t.Fatal(err)
401 }
402 defer sess.Close()
403 if err := sess.Shell(); err == nil {
404 t.Fatal("interactive shell was accepted")
405 }
406}
407
408func TestUnknownKeyRejected(t *testing.T) {
409 t.Parallel()
410 h := newHarness(t)
411 stranger, _ := keypair(t, filepath.Join(h.dir, "stranger"))
412 if _, err := xssh.Dial("tcp", h.addr, &xssh.ClientConfig{
413 User: "git",
414 Auth: []xssh.AuthMethod{publicKey(t, stranger)},
415 HostKeyCallback: xssh.InsecureIgnoreHostKey(),
416 Timeout: 5 * time.Second,
417 }); err == nil {
418 t.Fatal("a key that is not registered was accepted")
419 }
420}
421
422func TestRepoOutsideTheStoreIsRefused(t *testing.T) {
423 t.Parallel()
424 h := newHarness(t)
425 client := h.dial(t, h.keyPath)
426 for _, arg := range []string{
427 "/../../etc.git",
428 "/demo.git/../../x",
429 "/nothere.git",
430 } {
431 sess, err := client.NewSession()
432 if err != nil {
433 t.Fatal(err)
434 }
435 out, err := sess.CombinedOutput("git-upload-pack '" + arg + "'")
436 sess.Close()
437 if err == nil {
438 t.Fatalf("path %q was accepted: %s", arg, out)
439 }
440 }
441}
442
443func (h *harness) dial(t *testing.T, keyPath string) *xssh.Client {
444 t.Helper()
445 c, err := xssh.Dial("tcp", h.addr, &xssh.ClientConfig{
446 User: "git",
447 Auth: []xssh.AuthMethod{publicKey(t, keyPath)},
448 HostKeyCallback: xssh.InsecureIgnoreHostKey(),
449 Timeout: 5 * time.Second,
450 })
451 if err != nil {
452 t.Fatalf("dial: %v", err)
453 }
454 t.Cleanup(func() { c.Close() })
455 return c
456}
457
458func publicKey(t *testing.T, path string) xssh.AuthMethod {
459 t.Helper()
460 raw, err := os.ReadFile(path)
461 if err != nil {
462 t.Fatal(err)
463 }
464 blk, _ := pem.Decode(raw)
465 if blk == nil {
466 t.Fatal("bad private key")
467 }
468 key, err := xssh.ParseRawPrivateKey(pem.EncodeToMemory(blk))
469 if err != nil {
470 t.Fatal(err)
471 }
472 signer, err := xssh.NewSignerFromKey(key)
473 if err != nil {
474 t.Fatal(err)
475 }
476 return xssh.PublicKeys(signer)
477}
478
479func TestSplitCommand(t *testing.T) {
480 t.Parallel()
481 for _, c := range []struct {
482 in string
483 want []string
484 ok bool
485 }{
486 {"git-upload-pack '/demo.git'", []string{"git-upload-pack", "/demo.git"}, true},
487 {`git-upload-pack "/demo.git"`, []string{"git-upload-pack", "/demo.git"}, true},
488 {"git-receive-pack demo.git", []string{"git-receive-pack", "demo.git"}, true},
489 {"git-upload-pack '/unterminated", nil, false},
490 {"", nil, true},
491 } {
492 got, err := splitCommand(c.in)
493 if c.ok != (err == nil) {
494 t.Fatalf("%q err=%v", c.in, err)
495 }
496 if !c.ok {
497 continue
498 }
499 if len(got) != len(c.want) {
500 t.Fatalf("%q = %v, want %v", c.in, got, c.want)
501 }
502 for i := range got {
503 if got[i] != c.want[i] {
504 t.Fatalf("%q = %v, want %v", c.in, got, c.want)
505 }
506 }
507 }
508}
509
510func TestRepoNameValidation(t *testing.T) {
511 t.Parallel()
512 for _, c := range []struct {
513 in, want string
514 ok bool
515 }{
516 {"/demo.git", "demo", true},
517 {"demo.git", "demo", true},
518 {"/demo", "", false},
519 {"../demo.git", "", false},
520 {"/a/b.git", "", false},
521 } {
522 got, ok := repoName(c.in)
523 if ok != c.ok || got != c.want {
524 t.Errorf("repoName(%q) = %q,%v want %q,%v", c.in, got, ok, c.want, c.ok)
525 }
526 }
527}
528