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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 17m ago 4b8c9e1d
history blame raw 8.5 KiB
1package 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
9import (
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.
25func 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.
34func 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.
100func 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.
145func 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.
166func 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.
190func 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.
211func 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.
236func 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.
260func discardLogger() *slog.Logger {
261 return slog.New(slog.NewTextHandler(io.Discard, nil))
262}
263
264func 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