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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Blame · server/ssh/sshserver_test.go

Line attribution on main · 523 line(s)

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