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/runner.go +42−2
| @@ -2,7 +2,9 @@ package ssh | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | "errors" | ||
| 5 | 6 | "fmt" | |
| 7 | "io" | ||
| 6 | 8 | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/rodent-software/vole/ci/runs" | |
| @@ -42,8 +44,41 @@ const ( | |||
| 42 | 44 | // maxLeaseWait caps what a runner may ask for. The wait is a hint — the lease | |
| 43 | 45 | // remains the database's decision — so all a long wait holds is one SSH channel. | |
| 44 | 46 | maxLeaseWait = 60 * time.Second | |
| 47 | // maxRunnerRequestBytes bounds one request line. A runner request is a few | ||
| 48 | // hundred bytes of JSON; the field a hostile client would grow is --repos, and | ||
| 49 | // each name in it is looked up against the instance's repositories. The bound | ||
| 50 | // is per line, so a long-lived runner is not limited in total. | ||
| 51 | maxRunnerRequestBytes = 1 << 20 | ||
| 45 | 52 | ) | |
| 46 | 53 | ||
| 54 | // errRequestTooLarge ends a runner session whose request line outgrew the bound. | ||
| 55 | var errRequestTooLarge = errors.New("runner request line is too large") | ||
| 56 | |||
| 57 | // lineLimitReader fails once a single newline-terminated line passes limit bytes. | ||
| 58 | // It counts bytes as they cross the reader, so json.Decoder's internal buffering | ||
| 59 | // cannot hide a large request, and it resets at every newline so one oversized | ||
| 60 | // line is the only thing it refuses. | ||
| 61 | type lineLimitReader struct { | ||
| 62 | r io.Reader | ||
| 63 | limit int64 | ||
| 64 | line int64 | ||
| 65 | } | ||
| 66 | |||
| 67 | func (l *lineLimitReader) Read(p []byte) (int, error) { | ||
| 68 | n, err := l.r.Read(p) | ||
| 69 | for _, b := range p[:n] { | ||
| 70 | if b == '\n' { | ||
| 71 | l.line = 0 | ||
| 72 | continue | ||
| 73 | } | ||
| 74 | l.line++ | ||
| 75 | if l.line > l.limit { | ||
| 76 | return 0, errRequestTooLarge | ||
| 77 | } | ||
| 78 | } | ||
| 79 | return n, err | ||
| 80 | } | ||
| 81 | |||
| 47 | 82 | type runnerRequest struct { | |
| 48 | 83 | Op string `json:"op"` | |
| 49 | 84 | Run string `json:"run"` | |
| @@ -141,7 +176,7 @@ func (s *Server) serveRunner(ch xssh.Channel, user string) { | |||
| 141 | 176 | _ = json.NewEncoder(ch).Encode(runnerReply{Op: "error", Error: "the run queue is not configured on this server"}) | |
| 142 | 177 | return | |
| 143 | 178 | } | |
| 144 | dec := json.NewDecoder(ch) | ||
| 179 | dec := json.NewDecoder(&lineLimitReader{r: ch, limit: maxRunnerRequestBytes}) | ||
| 145 | 180 | enc := json.NewEncoder(ch) | |
| 146 | 181 | owner := user | |
| 147 | 182 | // Presence is one row per channel: a pool of three runners on one account is | |
| @@ -167,7 +202,12 @@ func (s *Server) serveRunner(ch xssh.Channel, user string) { | |||
| 167 | 202 | for { | |
| 168 | 203 | var req runnerRequest | |
| 169 | 204 | if err := dec.Decode(&req); err != nil { | |
| 170 | return // disconnected | ||
| 205 | if errors.Is(err, errRequestTooLarge) { | ||
| 206 | _ = enc.Encode(runnerReply{Op: "error", Error: fmt.Sprintf( | ||
| 207 | "a runner request must fit in %d bytes", maxRunnerRequestBytes)}) | ||
| 208 | cfg.Log.Warn("runner: refused an oversized request", "user", user) | ||
| 209 | } | ||
| 210 | return // disconnected, or refused | ||
| 171 | 211 | } | |
| 172 | 212 | switch req.Op { | |
| 173 | 213 | case "lease": | |