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.
| 1 | package ssh |
| 2 | |
| 3 | // The SSH listener is the only write transport, so it is the one place an |
| 4 | // anonymous client can spend instance resources before it has proved anything. |
| 5 | // These tests hold a connection without handshaking, open channels without using |
| 6 | // them, and send a request line larger than the service will parse — the three |
| 7 | // shapes of a flood — and assert that each is bounded rather than absorbed. |
| 8 | |
| 9 | import ( |
| 10 | "bufio" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "net" |
| 14 | "os" |
| 15 | "path/filepath" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | xssh "golang.org/x/crypto/ssh" |
| 21 | "rodent.software/vole/internal/secure" |
| 22 | ) |
| 23 | |
| 24 | // readUntil is a bounded read of whatever the server says before it hangs up. |
| 25 | func readUntil(t *testing.T, c net.Conn) string { |
| 26 | t.Helper() |
| 27 | _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) |
| 28 | raw, _ := io.ReadAll(io.LimitReader(c, 4096)) |
| 29 | return string(raw) |
| 30 | } |
| 31 | |
| 32 | // A client that opens a socket and sends nothing must be cut off by the handshake |
| 33 | // deadline, and the connection slot has to come back when it is. |
| 34 | func TestHandshakeSlowlorisIsCutOff(t *testing.T) { |
| 35 | t.Parallel() |
| 36 | h := newHarnessWith(t, func(c *Config) { |
| 37 | c.HandshakeTimeout = 150 * time.Millisecond |
| 38 | c.MaxConnections = 1 |
| 39 | }) |
| 40 | |
| 41 | idle, err := net.Dial("tcp", h.addr) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | defer idle.Close() |
| 46 | |
| 47 | // The only slot is taken by the idle connection, so the next one is refused |
| 48 | // without a handshake. |
| 49 | refused, err := net.Dial("tcp", h.addr) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if got := readUntil(t, refused); got != "" { |
| 54 | t.Fatalf("the refused connection was answered: %q", got) |
| 55 | } |
| 56 | if _, err := refused.Read(make([]byte, 1)); err == nil { |
| 57 | t.Fatal("the refused connection stayed open") |
| 58 | } |
| 59 | refused.Close() |
| 60 | |
| 61 | // The slowloris is dropped when its handshake deadline passes. The server has |
| 62 | // already written its version banner, so the connection is read to the end |
| 63 | // rather than once. |
| 64 | start := time.Now() |
| 65 | _ = idle.SetReadDeadline(time.Now().Add(5 * time.Second)) |
| 66 | buf := make([]byte, 512) |
| 67 | for { |
| 68 | if _, err := idle.Read(buf); err != nil { |
| 69 | if err == io.EOF { |
| 70 | break // the server hung up |
| 71 | } |
| 72 | t.Fatalf("the idle connection was not closed: %v", err) |
| 73 | } |
| 74 | } |
| 75 | if waited := time.Since(start); waited > 3*time.Second { |
| 76 | t.Fatalf("the idle connection was held for %s", waited) |
| 77 | } |
| 78 | // ...and its slot comes back: a real client can still connect. |
| 79 | deadline := time.Now().Add(5 * time.Second) |
| 80 | for { |
| 81 | client, err := xssh.Dial("tcp", h.addr, &xssh.ClientConfig{ |
| 82 | User: "git", |
| 83 | Auth: []xssh.AuthMethod{publicKey(t, h.keyPath)}, |
| 84 | HostKeyCallback: xssh.InsecureIgnoreHostKey(), |
| 85 | Timeout: 2 * time.Second, |
| 86 | }) |
| 87 | if err == nil { |
| 88 | client.Close() |
| 89 | return |
| 90 | } |
| 91 | if time.Now().After(deadline) { |
| 92 | t.Fatalf("the connection slot never came back: %v", err) |
| 93 | } |
| 94 | time.Sleep(20 * time.Millisecond) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // One connection may hold only so many session channels. Past the cap the server |
| 99 | // rejects the channel rather than starting a goroutine for it. |
| 100 | func TestChannelFloodIsRejected(t *testing.T) { |
| 101 | t.Parallel() |
| 102 | h := newHarness(t) |
| 103 | client := h.dial(t, h.keyPath) |
| 104 | |
| 105 | open := make([]*xssh.Session, 0, maxChannelsPerConnection) |
| 106 | for i := 0; i < maxChannelsPerConnection; i++ { |
| 107 | sess, err := client.NewSession() |
| 108 | if err != nil { |
| 109 | t.Fatalf("session %d of %d was refused: %v", i+1, maxChannelsPerConnection, err) |
| 110 | } |
| 111 | open = append(open, sess) |
| 112 | } |
| 113 | defer func() { |
| 114 | for _, s := range open { |
| 115 | s.Close() |
| 116 | } |
| 117 | }() |
| 118 | |
| 119 | if sess, err := client.NewSession(); err == nil { |
| 120 | sess.Close() |
| 121 | t.Fatalf("%d channels were accepted, want at most %d", |
| 122 | maxChannelsPerConnection+1, maxChannelsPerConnection) |
| 123 | } |
| 124 | |
| 125 | // Closing one releases a slot, so the cap is a bound on concurrency and not a |
| 126 | // lifetime budget. |
| 127 | open[0].Close() |
| 128 | open = open[1:] |
| 129 | deadline := time.Now().Add(5 * time.Second) |
| 130 | for { |
| 131 | sess, err := client.NewSession() |
| 132 | if err == nil { |
| 133 | sess.Close() |
| 134 | return |
| 135 | } |
| 136 | if time.Now().After(deadline) { |
| 137 | t.Fatalf("a closed channel did not release its slot: %v", err) |
| 138 | } |
| 139 | time.Sleep(20 * time.Millisecond) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // The exec line is the client's only payload. A command far longer than any git |
| 144 | // verb is refused before it is parsed. |
| 145 | func TestOversizedCommandIsRefused(t *testing.T) { |
| 146 | t.Parallel() |
| 147 | h := newHarness(t) |
| 148 | client := h.dial(t, h.keyPath) |
| 149 | |
| 150 | sess, err := client.NewSession() |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | defer sess.Close() |
| 155 | out, err := sess.CombinedOutput(strings.Repeat("a", maxCommandBytes+1)) |
| 156 | if err == nil { |
| 157 | t.Fatalf("an oversized command was accepted: %q", out) |
| 158 | } |
| 159 | if !strings.Contains(string(out), "longer than") { |
| 160 | t.Fatalf("the refusal does not say why: %q", out) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // A runner request is a few hundred bytes. The bound is per line, and crossing it |
| 165 | // ends the session with an error rather than growing with the client's input. |
| 166 | func TestRunnerRequestLineIsBounded(t *testing.T) { |
| 167 | t.Parallel() |
| 168 | h := newHarness(t) |
| 169 | c := h.runner(t, h.keyPath) |
| 170 | |
| 171 | // A --repos list is a field a client can grow without bound, and every name in |
| 172 | // it would otherwise be looked up. |
| 173 | huge := runnerRequest{Op: "lease", Repos: []string{ |
| 174 | strings.Repeat("r", int(maxRunnerRequestBytes)+1024), |
| 175 | }} |
| 176 | if err := c.enc.Encode(huge); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | var rep runnerReply |
| 180 | if err := c.dec.Decode(&rep); err != nil { |
| 181 | t.Fatalf("no reply to the oversized request: %v", err) |
| 182 | } |
| 183 | if rep.Op != "error" || !strings.Contains(rep.Error, "must fit") { |
| 184 | t.Fatalf("reply = %+v, want an error naming the bound", rep) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // The chunked reader exists so json.Decoder's buffering cannot hide a large |
| 189 | // request, and so the bound is per line rather than per session. |
| 190 | func TestLineLimitReaderIsPerLine(t *testing.T) { |
| 191 | t.Parallel() |
| 192 | lim := &lineLimitReader{r: strings.NewReader(`{"a":1}` + "\n" + `{"b":2}` + "\n"), limit: 8} |
| 193 | br := bufio.NewReader(lim) |
| 194 | for i := 0; i < 2; i++ { |
| 195 | line, err := br.ReadString('\n') |
| 196 | if err != nil { |
| 197 | t.Fatalf("line %d: %v", i+1, err) |
| 198 | } |
| 199 | if strings.TrimSpace(line) == "" { |
| 200 | t.Fatalf("line %d was empty", i+1) |
| 201 | } |
| 202 | } |
| 203 | if _, err := bufio.NewReader(&lineLimitReader{r: strings.NewReader(`{"longer":true}`), limit: 8}). |
| 204 | ReadString('\n'); err == nil { |
| 205 | t.Fatal("a line past the limit was accepted") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // A saturated process pool turns the command away after a bounded wait instead of |
| 210 | // parking the session forever. |
| 211 | func TestGitProcessCapRefusesAfterAWait(t *testing.T) { |
| 212 | t.Parallel() |
| 213 | h := newHarnessWith(t, func(c *Config) { c.MaxGitProcesses = 1 }) |
| 214 | srv := h.srv |
| 215 | |
| 216 | release, ok := srv.acquireGitProcess(time.Second) |
| 217 | if !ok { |
| 218 | t.Fatal("the only slot was not available") |
| 219 | } |
| 220 | start := time.Now() |
| 221 | if _, ok := srv.acquireGitProcess(100 * time.Millisecond); ok { |
| 222 | t.Fatal("a second process was started with the pool full") |
| 223 | } |
| 224 | if waited := time.Since(start); waited < 100*time.Millisecond || waited > 2*time.Second { |
| 225 | t.Fatalf("waited %s for a slot, want a bounded wait", waited) |
| 226 | } |
| 227 | release() |
| 228 | if _, ok := srv.acquireGitProcess(time.Second); !ok { |
| 229 | t.Fatal("the slot was not released") |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // The host key is the instance's long-term secret. A key left readable by other |
| 234 | // accounts is corrected when the server loads it, and the correction is not |
| 235 | // silent. |
| 236 | func TestLooseHostKeyModeIsTightened(t *testing.T) { |
| 237 | t.Parallel() |
| 238 | dir := t.TempDir() |
| 239 | path := filepath.Join(dir, "host_key") |
| 240 | if _, err := loadOrCreateHostKey(path, discardLogger()); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if err := os.Chmod(path, 0o644); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if _, err := loadOrCreateHostKey(path, discardLogger()); err != nil { |
| 247 | t.Fatalf("a group/world-readable host key did not load: %v", err) |
| 248 | } |
| 249 | st, err := os.Stat(path) |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if st.Mode().Perm() != secure.FileMode { |
| 254 | t.Fatalf("host key mode = %04o, want %04o", st.Mode().Perm(), secure.FileMode) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // discardLogger is a logger that keeps the "tightened permissions" line out of |
| 259 | // the test output. |
| 260 | func discardLogger() *slog.Logger { |
| 261 | return slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 262 | } |
| 263 | |
| 264 | func TestHandshakeDeadlineIsClearedForTheSession(t *testing.T) { |
| 265 | t.Parallel() |
| 266 | h := newHarnessWith(t, func(c *Config) { c.HandshakeTimeout = 100 * time.Millisecond }) |
| 267 | client := h.dial(t, h.keyPath) |
| 268 | |
| 269 | sess, err := client.NewSession() |
| 270 | if err != nil { |
| 271 | t.Fatalf("a session after the handshake: %v", err) |
| 272 | } |
| 273 | // Outlive the handshake deadline on a session that is open but idle. If the |
| 274 | // deadline survived the handshake, this channel would be cut when it ran. |
| 275 | time.Sleep(300 * time.Millisecond) |
| 276 | out, err := sess.Output("vole-whoami") |
| 277 | if err != nil { |
| 278 | t.Fatalf("the session was cut by the handshake deadline: %v", err) |
| 279 | } |
| 280 | if !strings.Contains(string(out), "writer") { |
| 281 | t.Fatalf("vole-whoami answered %q", out) |
| 282 | } |
| 283 | sess.Close() |
| 284 | } |
| 285 |