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/server.go +184−5

@@ -37,6 +37,27 @@ import (
37 37
38 38 const serverVersion = "SSH-2.0-vole"
39 39
40 const (
41 // defaultHandshakeTimeout bounds one connection's handshake. A client that opens
42 // a socket and says nothing would otherwise hold a connection slot, a goroutine
43 // and a file descriptor for as long as it liked. The handshake is a few round
44 // trips, so this is generous for a real client and fatal for a slowloris.
45 defaultHandshakeTimeout = 20 * time.Second
46 // maxChannelsPerConnection bounds the session channels one connection may have
47 // open at once. Each accepted channel starts a goroutine and may start a
48 // process, so the count is the client's leverage on the instance. Git opens one
49 // or two; a session pool opens one per runner.
50 maxChannelsPerConnection = 8
51 // maxCommandBytes bounds the exec request line. The longest legitimate command
52 // is a git verb with a repository path, so anything near this is a client
53 // sending a payload it expects the server to parse.
54 maxCommandBytes = 4 << 10
55 // gitProcessWait is how long a git command waits for a process slot when the
56 // instance is at its cap. A short wait absorbs a burst; past it the client is
57 // told to come back rather than parked forever.
58 gitProcessWait = 30 * time.Second
59 )
60
40 61 type Config struct {
41 62 Identity *identity.Store
42 63 Repos *repo.Store
@@ -70,6 +91,15 @@ type Config struct {
70 91 // PublicURL is where a browser reads this instance, handed to `vole-whoami` so
71 92 // the CLI can print a link to what it just created. Empty is fine.
72 93 PublicURL string
94 // MaxConnections bounds simultaneous connections on the listener. Zero uses a
95 // default; see server/ssh's maxConnections.
96 MaxConnections int
97 // MaxGitProcesses bounds git processes across all connections. Zero uses a
98 // default; see server/ssh's maxGitProcesses.
99 MaxGitProcesses int
100 // HandshakeTimeout bounds how long a connection may take to complete the SSH
101 // handshake. Zero uses the default.
102 HandshakeTimeout time.Duration
73 103 }
74 104
75 105 type Server struct {
@@ -77,6 +107,41 @@ type Server struct {
77 107 sshCfg *xssh.ServerConfig
78 108 mu sync.Mutex
79 109 ln net.Listener
110
111 // conns holds one token per open connection, and gitSlots one per running git
112 // process. Both are channels rather than counters because taking a token is
113 // also the wait.
114 conns chan struct{}
115 gitSlots chan struct{}
116
117 // refused throttles the "listener is full" warning. A flood is exactly when the
118 // log must not become the next unbounded resource.
119 refusedMu sync.Mutex
120 refusedAt time.Time
121 }
122
123 // maxConnections is the configured listener cap, or the default when unset.
124 func (c Config) maxConnections() int {
125 if c.MaxConnections > 0 {
126 return c.MaxConnections
127 }
128 return 64
129 }
130
131 // handshakeTimeout is the configured handshake bound, or the default when unset.
132 func (c Config) handshakeTimeout() time.Duration {
133 if c.HandshakeTimeout > 0 {
134 return c.HandshakeTimeout
135 }
136 return defaultHandshakeTimeout
137 }
138
139 // maxGitProcesses is the configured process cap, or the default when unset.
140 func (c Config) maxGitProcesses() int {
141 if c.MaxGitProcesses > 0 {
142 return c.MaxGitProcesses
143 }
144 return 16
80 145 }
81 146
82 147 // New loads or creates the host key and prepares the SSH config.
@@ -84,11 +149,15 @@ func New(cfg Config) (*Server, error) {
84 149 if cfg.Log == nil {
85 150 cfg.Log = slog.Default()
86 151 }
87 signer, err := loadOrCreateHostKey(cfg.HostKeyPath)
152 signer, err := loadOrCreateHostKey(cfg.HostKeyPath, cfg.Log)
88 153 if err != nil {
89 154 return nil, err
90 155 }
91 s := &Server{cfg: cfg}
156 s := &Server{
157 cfg: cfg,
158 conns: make(chan struct{}, cfg.maxConnections()),
159 gitSlots: make(chan struct{}, cfg.maxGitProcesses()),
160 }
92 161 s.sshCfg = &xssh.ServerConfig{
93 162 ServerVersion: serverVersion,
94 163 MaxAuthTries: 6,
@@ -130,7 +199,57 @@ func (s *Server) Serve(ln net.Listener) error {
130 199 }
131 200 return err
132 201 }
133 go s.handleConn(conn)
202 // Past the cap the connection is refused here, before a handshake, a
203 // goroutine or a git process can be spent on it. The accept loop keeps
204 // running, so the listener is never wedged behind a flood either.
205 select {
206 case s.conns <- struct{}{}:
207 default:
208 s.logRefused(conn)
209 conn.Close()
210 continue
211 }
212 go func(c net.Conn) {
213 defer func() { <-s.conns }()
214 s.handleConn(c)
215 }(conn)
216 }
217 }
218
219 // logRefused reports a connection the listener had no room for, at most once every
220 // ten seconds so a flood cannot fill the disk with the news.
221 func (s *Server) logRefused(conn net.Conn) {
222 s.refusedMu.Lock()
223 now := time.Now()
224 silent := now.Sub(s.refusedAt) < 10*time.Second
225 if !silent {
226 s.refusedAt = now
227 }
228 s.refusedMu.Unlock()
229 if silent {
230 return
231 }
232 s.cfg.Log.Warn("ssh: connection refused, listener is at its cap",
233 "remote", conn.RemoteAddr().String(), "max", cap(s.conns))
234 }
235
236 // acquireGitProcess takes one of the instance's git process slots. It waits a
237 // bounded time so a burst queues briefly instead of failing instantly, and reports
238 // whether it got one; the returned function releases it.
239 func (s *Server) acquireGitProcess(wait time.Duration) (func(), bool) {
240 release := func() { <-s.gitSlots }
241 select {
242 case s.gitSlots <- struct{}{}:
243 return release, true
244 default:
245 }
246 t := time.NewTimer(wait)
247 defer t.Stop()
248 select {
249 case s.gitSlots <- struct{}{}:
250 return release, true
251 case <-t.C:
252 return nil, false
134 253 }
135 254 }
136 255
@@ -144,11 +263,21 @@ func (s *Server) Close() error {
144 263 }
145 264
146 265 func (s *Server) handleConn(c net.Conn) {
266 // The handshake is the one part of a connection with a deadline: a clone copies
267 // a repository and a push receives a pack, and both are legitimately slow.
268 if err := c.SetDeadline(time.Now().Add(s.cfg.handshakeTimeout())); err != nil {
269 c.Close()
270 return
271 }
147 272 sconn, chans, reqs, err := xssh.NewServerConn(c, s.sshCfg)
148 273 if err != nil {
149 274 c.Close()
150 275 return
151 276 }
277 if err := c.SetDeadline(time.Time{}); err != nil {
278 sconn.Close()
279 return
280 }
152 281 defer sconn.Close()
153 282 go xssh.DiscardRequests(reqs)
154 283
@@ -158,16 +287,30 @@ func (s *Server) handleConn(c net.Conn) {
158 287 fp := sconn.Permissions.Extensions["fingerprint"]
159 288 s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String())
160 289
290 // One token per open session channel. A flood is rejected at the channel layer
291 // rather than spawning a goroutine per request.
292 slots := make(chan struct{}, maxChannelsPerConnection)
161 293 for newCh := range chans {
162 294 if newCh.ChannelType() != "session" {
163 295 newCh.Reject(xssh.UnknownChannelType, "only session channels are supported")
164 296 continue
165 297 }
298 select {
299 case slots <- struct{}{}:
300 default:
301 newCh.Reject(xssh.Prohibited, fmt.Sprintf(
302 "at most %d open channels per connection", maxChannelsPerConnection))
303 continue
304 }
166 305 ch, chReqs, err := newCh.Accept()
167 306 if err != nil {
307 <-slots
168 308 continue
169 309 }
170 go s.handleSession(ch, chReqs, user, fp, sconn.RemoteAddr().String())
310 go func(ch xssh.Channel, reqs <-chan *xssh.Request) {
311 defer func() { <-slots }()
312 s.handleSession(ch, reqs, user, fp, sconn.RemoteAddr().String())
313 }(ch, chReqs)
171 314 }
172 315 }
173 316
@@ -205,6 +348,15 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem
205 348 exit(ch, 1)
206 349 }
207 350
351 // The exec line is the only client-supplied payload on this path. Refuse an
352 // oversized one before parsing it: the longest legitimate command is a git verb
353 // and a repository path.
354 if len(command) > maxCommandBytes {
355 s.cfg.Log.Warn("ssh: refused oversized command", "user", user, "bytes", len(command))
356 refuse("command line is longer than %d bytes", maxCommandBytes)
357 return
358 }
359
208 360 argv, err := splitCommand(command)
209 361 if err != nil {
210 362 refuse("malformed command")
@@ -223,6 +375,13 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem
223 375 // and the run results it needs live here, not in the caller's clone; the verb
224 376 // takes a repository name and a request id and answers one line.
225 377 if len(argv) >= 1 && argv[0] == "vole-change-merge" {
378 release, ok := s.acquireGitProcess(gitProcessWait)
379 if !ok {
380 s.cfg.Log.Warn("ssh: no git process slot for a merge", "user", user)
381 refuse("the server is busy running other git commands; try again shortly")
382 return
383 }
384 defer release()
226 385 req.Reply(true, nil)
227 386 exit(ch, s.serveChangeMerge(ch, argv, user, remote))
228 387 return
@@ -308,6 +467,17 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem
308 467 return
309 468 }
310 469 }
470 // One process slot per git command, instance-wide. The connection and channel
471 // caps bound how many sessions can ask; this bounds what they cost together.
472 release, ok := s.acquireGitProcess(gitProcessWait)
473 if !ok {
474 s.cfg.Log.Warn("ssh: no git process slot", "user", user, "repo", name,
475 "max", cap(s.gitSlots), "wait", gitProcessWait.String())
476 refuse("the server is busy running other git commands; try again shortly")
477 return
478 }
479 defer release()
480
311 481 if err := req.Reply(true, nil); err != nil {
312 482 return
313 483 }
@@ -420,7 +590,16 @@ func exit(ch xssh.Channel, code int) {
420 590 ch.SendRequest("exit-status", false, xssh.Marshal(struct{ Status uint32 }{uint32(code)}))
421 591 }
422 592
423 func loadOrCreateHostKey(path string) (xssh.Signer, error) {
593 func loadOrCreateHostKey(path string, log *slog.Logger) (xssh.Signer, error) {
594 // The host key is the instance's long-term secret. A copy another account can
595 // read is already disclosed, so a loose mode is corrected before the key is
596 // used, and a mode that cannot be corrected fails startup: an instance that
597 // cannot protect its key must not serve it.
598 if changed, err := secure.HardenFileIfExists(path); err != nil {
599 return nil, err
600 } else if changed {
601 log.Warn("tightened the SSH host key permissions", "path", path)
602 }
424 603 if data, err := os.ReadFile(path); err == nil {
425 604 blk, _ := pem.Decode(data)
426 605 if blk == nil {