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.
Bound what an unauthenticated SSH client can spend
78cd3437a597b0155682d2e8d5129bf7cdeac85d · Keenan Nemetz · 12d ago · parent b1499c72
Bound what an unauthenticated SSH client can spend
The SSH listener accepted every connection, gave each one a goroutine, and
started a process per session channel. A client that opened a socket and
never finished the handshake held all three for as long as it liked, and a
client that opened channels held a goroutine each. The host key was
hardened by `vole serve` on the way in, but the SSH package on its own
would happily load a key the whole machine could read.
Four bounds, all enforced before the expensive thing happens:
- A handshake deadline, cleared once the handshake completes so a clone
or a push is not cut. `ssh_handshake_timeout_seconds`, default 20.
- `ssh_max_connections` (default 64) tokens, one per open connection. Past
the cap the connection is closed before the handshake, the goroutine or
the process, and the accept loop keeps running so the listener cannot be
wedged. The "listener is full" warning is throttled to once per 10s so a
flood cannot become a disk problem instead.
- Eight session channels per connection, rejected at the channel layer
rather than by spawning a goroutine per request.
- `ssh_max_git_processes` (default 16) across every connection, covering
upload-pack, receive-pack and vole-change-merge. A command waits up to
30s for a slot and is then told to retry, rather than parked forever.
Payloads are bounded too: an exec line over 4 KiB is refused before it is
parsed, and a vole-runner request line over 1 MiB ends the session with an
error. The runner bound is a per-line reader, so json.Decoder's buffering
cannot hide an oversized request and a long-lived runner is not limited in
total.
Loading an existing host key now tightens it to 0600 and warns, and a mode
that cannot be tightened fails startup, so the package is safe on its own
rather than only behind `vole serve`.
Tests hold a connection without handshaking (it is dropped and its slot
returns), flood one connection with channels (the ninth is rejected and
closing one releases a slot), send an oversized exec line and an oversized
runner request (both refused), saturate the process pool (a bounded wait,
then a refusal), and load a 0644 host key (tightened).
server/ssh/limits_test.go +285
| @@ -0,0 +1,285 @@ | |||
| 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 | "github.com/rodent-software/vole/internal/secure" | ||
| 21 | xssh "golang.org/x/crypto/ssh" | ||
| 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 the one field a client can grow without bound, and every | ||
| 172 | // name in it would otherwise be looked up. | ||
| 173 | huge := runnerRequest{Op: "lease", Repos: make([]string, 0, 64)} | ||
| 174 | for i := 0; i < 64; i++ { | ||
| 175 | huge.Repos = append(huge.Repos, strings.Repeat("r", 32<<10)) | ||
| 176 | } | ||
| 177 | if err := c.enc.Encode(huge); err != nil { | ||
| 178 | t.Fatal(err) | ||
| 179 | } | ||
| 180 | var rep runnerReply | ||
| 181 | if err := c.dec.Decode(&rep); err != nil { | ||
| 182 | t.Fatalf("no reply to the oversized request: %v", err) | ||
| 183 | } | ||
| 184 | if rep.Op != "error" || !strings.Contains(rep.Error, "must fit") { | ||
| 185 | t.Fatalf("reply = %+v, want an error naming the bound", rep) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | // The chunked reader exists so json.Decoder's buffering cannot hide a large | ||
| 190 | // request, and so the bound is per line rather than per session. | ||
| 191 | func TestLineLimitReaderIsPerLine(t *testing.T) { | ||
| 192 | t.Parallel() | ||
| 193 | lim := &lineLimitReader{r: strings.NewReader(`{"a":1}` + "\n" + `{"b":2}` + "\n"), limit: 8} | ||
| 194 | br := bufio.NewReader(lim) | ||
| 195 | for i := 0; i < 2; i++ { | ||
| 196 | line, err := br.ReadString('\n') | ||
| 197 | if err != nil { | ||
| 198 | t.Fatalf("line %d: %v", i+1, err) | ||
| 199 | } | ||
| 200 | if strings.TrimSpace(line) == "" { | ||
| 201 | t.Fatalf("line %d was empty", i+1) | ||
| 202 | } | ||
| 203 | } | ||
| 204 | if _, err := bufio.NewReader(&lineLimitReader{r: strings.NewReader(`{"longer":true}`), limit: 8}). | ||
| 205 | ReadString('\n'); err == nil { | ||
| 206 | t.Fatal("a line past the limit was accepted") | ||
| 207 | } | ||
| 208 | } | ||
| 209 | |||
| 210 | // A saturated process pool turns the command away after a bounded wait instead of | ||
| 211 | // parking the session forever. | ||
| 212 | func TestGitProcessCapRefusesAfterAWait(t *testing.T) { | ||
| 213 | t.Parallel() | ||
| 214 | h := newHarnessWith(t, func(c *Config) { c.MaxGitProcesses = 1 }) | ||
| 215 | srv := h.srv | ||
| 216 | |||
| 217 | release, ok := srv.acquireGitProcess(time.Second) | ||
| 218 | if !ok { | ||
| 219 | t.Fatal("the only slot was not available") | ||
| 220 | } | ||
| 221 | start := time.Now() | ||
| 222 | if _, ok := srv.acquireGitProcess(100 * time.Millisecond); ok { | ||
| 223 | t.Fatal("a second process was started with the pool full") | ||
| 224 | } | ||
| 225 | if waited := time.Since(start); waited < 100*time.Millisecond || waited > 2*time.Second { | ||
| 226 | t.Fatalf("waited %s for a slot, want a bounded wait", waited) | ||
| 227 | } | ||
| 228 | release() | ||
| 229 | if _, ok := srv.acquireGitProcess(time.Second); !ok { | ||
| 230 | t.Fatal("the slot was not released") | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | // The host key is the instance's long-term secret. A key left readable by other | ||
| 235 | // accounts is corrected when the server loads it, and the correction is not | ||
| 236 | // silent. | ||
| 237 | func TestLooseHostKeyModeIsTightened(t *testing.T) { | ||
| 238 | t.Parallel() | ||
| 239 | dir := t.TempDir() | ||
| 240 | path := filepath.Join(dir, "host_key") | ||
| 241 | if _, err := loadOrCreateHostKey(path, discardLogger()); err != nil { | ||
| 242 | t.Fatal(err) | ||
| 243 | } | ||
| 244 | if err := os.Chmod(path, 0o644); err != nil { | ||
| 245 | t.Fatal(err) | ||
| 246 | } | ||
| 247 | if _, err := loadOrCreateHostKey(path, discardLogger()); err != nil { | ||
| 248 | t.Fatalf("a group/world-readable host key did not load: %v", err) | ||
| 249 | } | ||
| 250 | st, err := os.Stat(path) | ||
| 251 | if err != nil { | ||
| 252 | t.Fatal(err) | ||
| 253 | } | ||
| 254 | if st.Mode().Perm() != secure.FileMode { | ||
| 255 | t.Fatalf("host key mode = %04o, want %04o", st.Mode().Perm(), secure.FileMode) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | // discardLogger is a logger that keeps the "tightened permissions" line out of | ||
| 260 | // the test output. | ||
| 261 | func discardLogger() *slog.Logger { | ||
| 262 | return slog.New(slog.NewTextHandler(io.Discard, nil)) | ||
| 263 | } | ||
| 264 | |||
| 265 | func TestHandshakeDeadlineIsClearedForTheSession(t *testing.T) { | ||
| 266 | t.Parallel() | ||
| 267 | h := newHarnessWith(t, func(c *Config) { c.HandshakeTimeout = 100 * time.Millisecond }) | ||
| 268 | client := h.dial(t, h.keyPath) | ||
| 269 | |||
| 270 | sess, err := client.NewSession() | ||
| 271 | if err != nil { | ||
| 272 | t.Fatalf("a session after the handshake: %v", err) | ||
| 273 | } | ||
| 274 | // Outlive the handshake deadline on a session that is open but idle. If the | ||
| 275 | // deadline survived the handshake, this channel would be cut when it ran. | ||
| 276 | time.Sleep(300 * time.Millisecond) | ||
| 277 | out, err := sess.Output("vole-whoami") | ||
| 278 | if err != nil { | ||
| 279 | t.Fatalf("the session was cut by the handshake deadline: %v", err) | ||
| 280 | } | ||
| 281 | if !strings.Contains(string(out), "writer") { | ||
| 282 | t.Fatalf("vole-whoami answered %q", out) | ||
| 283 | } | ||
| 284 | sess.Close() | ||
| 285 | } | ||