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.
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.
core/lfs/fuzz_test.go +136
| @@ -0,0 +1,136 @@ | |||
| 1 | package lfs | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // frame writes one text packet the way a client does, so the seeds below are the | ||
| 11 | // shapes a real pusher sends rather than plausible-looking bytes. | ||
| 12 | func frame(payload string) string { | ||
| 13 | return fmt.Sprintf("%04x%s", len(payload)+4, payload) | ||
| 14 | } | ||
| 15 | |||
| 16 | // FuzzPktReader drives the reader that turns a stream from a pusher into packet | ||
| 17 | // lengths and payloads. A length that is believed when it should not be is how a | ||
| 18 | // stream parser is turned into an allocation or a desynchronised connection. | ||
| 19 | // | ||
| 20 | // Properties: | ||
| 21 | // | ||
| 22 | // - It cannot return more bytes than it was given, so no packet invents data. | ||
| 23 | // - A control packet carries no payload. | ||
| 24 | // - What it reads survives being written again. The two sides of this protocol | ||
| 25 | // are this same file, and a round trip that disagrees means a client and the | ||
| 26 | // server would disagree about where a packet ends. | ||
| 27 | func FuzzPktReader(f *testing.F) { | ||
| 28 | for _, seed := range []string{ | ||
| 29 | frame("version https://git-lfs/1.0\n") + "0000", | ||
| 30 | frame("batch\n") + "0001" + frame("upload\n") + "0000", | ||
| 31 | frame("put\n") + "0001" + frame("123\n") + "0000", | ||
| 32 | frame("a") + frame("b") + "0000", | ||
| 33 | "", | ||
| 34 | "0000", | ||
| 35 | "0001", | ||
| 36 | "0002", | ||
| 37 | "0003", | ||
| 38 | "0004", | ||
| 39 | "0005a", | ||
| 40 | "zzzz", | ||
| 41 | "FFFF", | ||
| 42 | "ffff", | ||
| 43 | "ffffshort", | ||
| 44 | "0005a0000", | ||
| 45 | frame("x\n") + "0001" + "0000", | ||
| 46 | } { | ||
| 47 | f.Add(seed) | ||
| 48 | } | ||
| 49 | f.Fuzz(func(t *testing.T, stream string) { | ||
| 50 | pr := newPktReader(strings.NewReader(stream)) | ||
| 51 | var lines []string | ||
| 52 | read := 0 | ||
| 53 | for { | ||
| 54 | data, kind, err := pr.next() | ||
| 55 | if err != nil { | ||
| 56 | // Either the input ended between packets or it was refused. | ||
| 57 | break | ||
| 58 | } | ||
| 59 | if kind != -1 { | ||
| 60 | if data != nil { | ||
| 61 | t.Fatalf("control packet %d carried %d bytes", kind, len(data)) | ||
| 62 | } | ||
| 63 | continue | ||
| 64 | } | ||
| 65 | read += len(data) | ||
| 66 | lines = append(lines, strings.TrimRight(string(data), "\n")) | ||
| 67 | } | ||
| 68 | if read > len(stream) { | ||
| 69 | t.Fatalf("read %d bytes of payload from a %d byte stream", read, len(stream)) | ||
| 70 | } | ||
| 71 | if len(lines) == 0 { | ||
| 72 | return | ||
| 73 | } | ||
| 74 | // Write the lines back out and read them again. pkt appends the newline the | ||
| 75 | // framing counts, which is why the comparison is on the trimmed line. | ||
| 76 | var buf bytes.Buffer | ||
| 77 | pw := newPktWriter(&buf) | ||
| 78 | for _, line := range lines { | ||
| 79 | if err := pw.pkt(line); err != nil { | ||
| 80 | // An oversized line cannot be written back; the reader refuses it too. | ||
| 81 | return | ||
| 82 | } | ||
| 83 | } | ||
| 84 | if err := pw.flush(); err != nil { | ||
| 85 | t.Fatalf("flush: %v", err) | ||
| 86 | } | ||
| 87 | again := newPktReader(bytes.NewReader(buf.Bytes())) | ||
| 88 | for i, want := range lines { | ||
| 89 | data, kind, err := again.next() | ||
| 90 | if err != nil || kind != -1 { | ||
| 91 | t.Fatalf("line %d (%q) did not survive re-encoding: kind %d, err %v", i, want, kind, err) | ||
| 92 | } | ||
| 93 | if got := strings.TrimRight(string(data), "\n"); got != want { | ||
| 94 | t.Fatalf("line %d re-encoded as %q, want %q", i, got, want) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | }) | ||
| 98 | } | ||
| 99 | |||
| 100 | // FuzzReadBatch drives the two loops above the packet reader: the arguments up to | ||
| 101 | // a delimiter, then the object lines up to a flush. These are the loops that a | ||
| 102 | // stream can hold open, so the property is that each one terminates on the input | ||
| 103 | // it was given and returns no more than it contains. | ||
| 104 | func FuzzReadBatch(f *testing.F) { | ||
| 105 | for _, seed := range []string{ | ||
| 106 | frame("batch\n") + "0001" + frame("upload\n") + frame("oid size\n") + "0000", | ||
| 107 | frame("batch\n") + "0000", | ||
| 108 | "0001" + "0000", | ||
| 109 | "0001", | ||
| 110 | frame("a\n") + "0001" + frame("b\n") + frame("c\n") + "0000", | ||
| 111 | "", | ||
| 112 | "0000", | ||
| 113 | "zzzz", | ||
| 114 | "0005a" + "0000", | ||
| 115 | } { | ||
| 116 | f.Add(seed) | ||
| 117 | } | ||
| 118 | f.Fuzz(func(t *testing.T, stream string) { | ||
| 119 | args, lines, err := newPktReader(strings.NewReader(stream)).readBatch() | ||
| 120 | if err != nil { | ||
| 121 | return | ||
| 122 | } | ||
| 123 | returned := 0 | ||
| 124 | for _, list := range [][]string{args, lines} { | ||
| 125 | for _, line := range list { | ||
| 126 | returned += len(line) | ||
| 127 | if strings.HasSuffix(line, "\n") { | ||
| 128 | t.Fatalf("readBatch returned %q with its newline still attached", line) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | if returned > len(stream) { | ||
| 133 | t.Fatalf("readBatch returned %d bytes from a %d byte stream", returned, len(stream)) | ||
| 134 | } | ||
| 135 | }) | ||
| 136 | } | ||