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.

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 }