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.

Fuzz the parsers that read untrusted input

16bf0ebf8054de5044a52f93d156402542f0f71a · Keenan Nemetz · 10d ago · parent 70ba9a69

Fuzz the parsers that read untrusted input

The SSH exec line, the Git HTTP route, and the LFS packet stream are all
hand-written parsers on bytes a client controls, and each was covered only by a
table of examples someone thought to write.

Six targets, each asserting a property rather than waiting for a panic: the
command parser cannot invent bytes and its output survives being quoted back, a
route re-routes from its own parts and never names more than one path segment, a
repository argument is a name and not a path, and the packet reader's lines
survive being framed again. The seeds are the shapes the clients actually send,
so go test ./... keeps guarding them and a fuzz target costs a push nothing.

The route fuzzer found one gap on its first run: /git/.git/info/refs gave back an
empty repository name with ok set, and only the store refused it. A route that
answers ok now means a name the rest of the path applies to.

Closes #96.

server/ssh/fuzz_test.go +123

@@ -0,0 +1,123 @@
1 package ssh
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/rodent-software/vole/forge/repo"
8 )
9
10 // FuzzSplitCommand drives the parser that turns an exec line from an
11 // unauthenticated connection into the argv the server acts on. The seeds are the
12 // shapes git actually sends, so `go test ./...` keeps refusing those; the
13 // fuzzer's job is everything between them.
14 //
15 // Two properties are checked, and neither is "it does not panic", which is the
16 // floor rather than the point:
17 //
18 // - It cannot invent bytes. Every byte of every argument came from the command
19 // line: a quote is consumed and writes nothing, an escape is consumed and
20 // writes one byte.
21 // - What it produces survives being written back out. The client builds these
22 // command lines, and the server parses them; if a value does not round-trip
23 // through git's quoting, one end misreads the other.
24 func FuzzSplitCommand(f *testing.F) {
25 for _, seed := range []string{
26 "git-upload-pack '/demo.git'",
27 `git-upload-pack "/demo.git"`,
28 "git-receive-pack demo.git",
29 "git-upload-pack '/unterminated",
30 `git-upload-pack "half\`,
31 "git-upload-pack\t'demo.git'",
32 "git-upload-pack 'demo.git'",
33 "''",
34 "",
35 "vole-runner",
36 "vole-change-merge demo 01M5XCHRQFT0FTM0CJYRYWVD3V",
37 `git-upload-pack '/it'\''s.git'`,
38 } {
39 f.Add(seed)
40 }
41 f.Fuzz(func(t *testing.T, command string) {
42 argv, err := splitCommand(command)
43 if err != nil {
44 return
45 }
46 if total := argBytes(argv); total > len(command) {
47 t.Fatalf("splitCommand(%q) produced %d bytes of arguments from %d bytes", command, total, len(command))
48 }
49 quoted := make([]string, len(argv))
50 for i, arg := range argv {
51 quoted[i] = quoteArg(arg)
52 }
53 command2 := strings.Join(quoted, " ")
54 again, err := splitCommand(command2)
55 if err != nil {
56 t.Fatalf("re-quoted %q (from %q) does not parse: %v", command2, command, err)
57 }
58 if len(again) != len(argv) {
59 t.Fatalf("%q split to %d arguments, but %q splits to %d", command, len(argv), command2, len(again))
60 }
61 for i := range argv {
62 if again[i] != argv[i] {
63 t.Fatalf("%q: argument %d is %q, but %q gives %q", command, i, argv[i], command2, again[i])
64 }
65 }
66 })
67 }
68
69 func argBytes(argv []string) int {
70 total := 0
71 for _, arg := range argv {
72 total += len(arg)
73 }
74 return total
75 }
76
77 // quoteArg writes one argument the way git does: single quoted, with an embedded
78 // quote closing the quoting, escaping itself, and reopening it.
79 func quoteArg(arg string) string {
80 return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
81 }
82
83 // FuzzRepoName drives the step after parsing: the argument becomes a repository
84 // name, not a path. It is the same untrusted text, so it belongs with the parser
85 // that produces it — fuzzing one and not the other leaves the decision that
86 // actually touches storage untested.
87 func FuzzRepoName(f *testing.F) {
88 for _, seed := range []string{
89 "/demo.git",
90 "demo.git",
91 "/demo",
92 "../demo.git",
93 "/a/b.git",
94 "/.git",
95 "/demo.git/",
96 "//demo.git",
97 "/demo.git/extra",
98 "/..git",
99 "/.a.git",
100 "",
101 } {
102 f.Add(seed)
103 }
104 f.Fuzz(func(t *testing.T, arg string) {
105 name, ok := repoName(arg)
106 if !ok {
107 return
108 }
109 // A name is one segment of a path under the repositories directory, so
110 // nothing that could climb or descend may survive as a name.
111 if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
112 t.Fatalf("repoName(%q) = %q, which is a path rather than a name", arg, name)
113 }
114 if err := repo.ValidateName(name); err != nil {
115 t.Fatalf("repoName(%q) = %q, which the repository store refuses: %v", arg, name, err)
116 }
117 // The name has to be the one git sends back for it.
118 again, ok := repoName("/" + name + ".git")
119 if !ok || again != name {
120 t.Fatalf("repoName(%q) = %q, but \"/%s.git\" gives %q, %v", arg, name, name, again, ok)
121 }
122 })
123 }