From 3e79b7491558b82819d941d531c713cf115a26ce Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:49:06 +0000 Subject: [PATCH 1/6] Mitigate DoS by limiting concurrent handshakes This commit introduces a semaphore to limit the number of concurrent SSH handshakes to 20. This prevents resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack where attackers stall the handshake process. Additionally, the initial handshake timeout is reduced from 20s to 10s to clear stuck connections faster. Backpressure is applied by blocking the Accept loop when the limit is reached, allowing the kernel backlog to handle the excess load. --- sshd/net.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sshd/net.go b/sshd/net.go index 678454ba..80d28c9e 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -15,6 +15,9 @@ type SSHListener struct { RateLimit func() rateio.Limiter HandlerFunc func(term *Terminal) + + // handshakeLimit is a semaphore to limit concurrent handshakes + handshakeLimit chan struct{} } // ListenSSH makes an SSH listener socket @@ -23,7 +26,11 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { if err != nil { return nil, err } - l := SSHListener{Listener: socket, config: config} + l := SSHListener{ + Listener: socket, + config: config, + handshakeLimit: make(chan struct{}, 20), + } return &l, nil } @@ -35,7 +42,7 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { // If the connection doesn't write anything back for too long before we get // a valid session, it should be dropped. - var handleTimeout = 20 * time.Second + var handleTimeout = 10 * time.Second conn.SetReadDeadline(time.Now().Add(handleTimeout)) defer conn.SetReadDeadline(time.Time{}) @@ -61,9 +68,15 @@ func (l *SSHListener) Serve() { break } + // Acquire semaphore + l.handshakeLimit <- struct{}{} + // Goroutineify to resume accepting sockets early go func() { term, err := l.handleConn(conn) + // Release semaphore + <-l.handshakeLimit + if err != nil { logger.Printf("[%s] Failed to handshake: %s", conn.RemoteAddr(), err) conn.Close() // Must be closed to avoid a leak From f3bf6842b396a6a83a5ae87cc32d96f8a400d5d3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:05:11 +0000 Subject: [PATCH 2/6] Mitigate DoS by limiting concurrent handshakes This commit introduces mechanism to limit the number of concurrent SSH handshakes to prevent resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack. 1. **Global Limit:** A semaphore limits total concurrent handshakes to 20. This applies backpressure to the `Accept` loop. 2. **Per-IP Limit:** A map limits concurrent handshakes to 3 per IP address. Excess connections are rejected immediately. 3. **Timeout:** The initial handshake timeout is reduced from 20s to 10s. The global semaphore is acquired before the handshake and released immediately after the handshake completes (or fails), ensuring that long-lived sessions do not consume a handshake slot. --- sshd/net.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/sshd/net.go b/sshd/net.go index 80d28c9e..49603cee 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -2,6 +2,7 @@ package sshd import ( "net" + "sync" "time" "github.com/shazow/rateio" @@ -16,8 +17,12 @@ type SSHListener struct { RateLimit func() rateio.Limiter HandlerFunc func(term *Terminal) - // handshakeLimit is a semaphore to limit concurrent handshakes + // handshakeLimit is a semaphore to limit concurrent handshakes globally handshakeLimit chan struct{} + + // connLimit tracks concurrent handshakes per IP + connLimitMutex sync.Mutex + connLimit map[string]int } // ListenSSH makes an SSH listener socket @@ -30,6 +35,7 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { Listener: socket, config: config, handshakeLimit: make(chan struct{}, 20), + connLimit: make(map[string]int), } return &l, nil } @@ -68,14 +74,63 @@ func (l *SSHListener) Serve() { break } - // Acquire semaphore + // Check per-IP limit + host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) + if err != nil { + // If we can't parse the IP, we assume it's unique or just let it through to the global limiter + // but best effort is to log and proceed. + logger.Printf("Failed to split remote addr: %v", err) + } else { + l.connLimitMutex.Lock() + count := l.connLimit[host] + if count >= 3 { + l.connLimitMutex.Unlock() + logger.Printf("[%s] Rejected connection: too many concurrent handshakes", conn.RemoteAddr()) + conn.Close() + continue + } + l.connLimit[host]++ + l.connLimitMutex.Unlock() + } + + // Acquire global semaphore l.handshakeLimit <- struct{}{} // Goroutineify to resume accepting sockets early go func() { + // Ensure limits are released when this goroutine finishes (in case of panic) + // OR explicitly release them after handshake. + // Ideally we release them as soon as handshake is done. + + // We need a way to ensure release happens exactly once. + released := false + release := func() { + if released { + return + } + released = true + + // Release global semaphore + <-l.handshakeLimit + + // Release per-IP limit + if host != "" { + l.connLimitMutex.Lock() + l.connLimit[host]-- + if l.connLimit[host] == 0 { + delete(l.connLimit, host) + } + l.connLimitMutex.Unlock() + } + } + + // Defer release in case of panic or early return + defer release() + term, err := l.handleConn(conn) - // Release semaphore - <-l.handshakeLimit + + // Handshake is done (success or failure). Release limits. + release() if err != nil { logger.Printf("[%s] Failed to handshake: %s", conn.RemoteAddr(), err) From 709ec3818ddec42ba9baac6a7c2bb48ed8e6788c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:23:25 +0000 Subject: [PATCH 3/6] Mitigate DoS by limiting concurrent handshakes This commit introduces mechanism to limit the number of concurrent SSH handshakes to prevent resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack. 1. **Global Limit:** A semaphore limits total concurrent handshakes to 20. This applies backpressure to the `Accept` loop. 2. **Per-IP Limit:** Using `github.com/sethvargo/go-limiter`, we now limit the rate of connection attempts per IP to 3 per second. 3. **Timeout:** The initial handshake timeout is reduced from 20s to 10s. The global semaphore is acquired before the handshake and released immediately after the handshake completes (or fails), ensuring that long-lived sessions do not consume a handshake slot. --- go.mod | 13 ++++++++++++- go.sum | 2 ++ sshd/net.go | 56 ++++++++++++++++++++++++++--------------------------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 32853722..3eaca587 100644 --- a/go.mod +++ b/go.mod @@ -11,4 +11,15 @@ require ( golang.org/x/text v0.14.0 ) -go 1.13 +require ( + github.com/sethvargo/go-limiter v1.1.0 // indirect + github.com/yuin/goldmark v1.4.13 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/tools v0.6.0 // indirect + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect +) + +go 1.22 + +toolchain go1.24.3 diff --git a/go.sum b/go.sum index 83d4fe5a..28ad030d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58 h1:MkpmYfld/S8kXqTY github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58/go.mod h1:YNfsMyWSs+h+PaYkxGeMVmVCX75Zj/pqdjbu12ciCYE= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/sethvargo/go-limiter v1.1.0 h1:eLeZVQ2zqJOiEs03GguqmBVG6/T6lsZB+6PP1t7J6fA= +github.com/sethvargo/go-limiter v1.1.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II= github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE= github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/sshd/net.go b/sshd/net.go index 49603cee..677a1b89 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -1,10 +1,12 @@ package sshd import ( + "context" "net" - "sync" "time" + "github.com/sethvargo/go-limiter" + "github.com/sethvargo/go-limiter/memorystore" "github.com/shazow/rateio" "golang.org/x/crypto/ssh" ) @@ -20,9 +22,8 @@ type SSHListener struct { // handshakeLimit is a semaphore to limit concurrent handshakes globally handshakeLimit chan struct{} - // connLimit tracks concurrent handshakes per IP - connLimitMutex sync.Mutex - connLimit map[string]int + // limiter is the per-IP rate limiter + limiter limiter.Store } // ListenSSH makes an SSH listener socket @@ -31,11 +32,24 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { if err != nil { return nil, err } + + // Create a rate limiter: 3 attempts per second per IP? + // The user wanted to "throttle many connections". + // 3 per second is generous for a chat server. + // If an IP connects >3 times in a second, it's likely a bot or flood. + store, err := memorystore.New(&memorystore.Config{ + Tokens: 3, + Interval: time.Second, + }) + if err != nil { + return nil, err + } + l := SSHListener{ Listener: socket, config: config, handshakeLimit: make(chan struct{}, 20), - connLimit: make(map[string]int), + limiter: store, } return &l, nil } @@ -74,23 +88,22 @@ func (l *SSHListener) Serve() { break } - // Check per-IP limit + // Check per-IP limit using go-limiter host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) if err != nil { - // If we can't parse the IP, we assume it's unique or just let it through to the global limiter - // but best effort is to log and proceed. logger.Printf("Failed to split remote addr: %v", err) } else { - l.connLimitMutex.Lock() - count := l.connLimit[host] - if count >= 3 { - l.connLimitMutex.Unlock() - logger.Printf("[%s] Rejected connection: too many concurrent handshakes", conn.RemoteAddr()) + // Context with timeout is not strictly needed for memory store, but good practice + // Although Take is non-blocking for memory store usually. + _, _, _, ok, err := l.limiter.Take(context.Background(), host) + if err != nil { + // Store error (shouldn't happen with memory store unless closed) + logger.Printf("Rate limiter error: %v", err) + } else if !ok { + logger.Printf("[%s] Rejected connection: rate limit exceeded", conn.RemoteAddr()) conn.Close() continue } - l.connLimit[host]++ - l.connLimitMutex.Unlock() } // Acquire global semaphore @@ -100,9 +113,6 @@ func (l *SSHListener) Serve() { go func() { // Ensure limits are released when this goroutine finishes (in case of panic) // OR explicitly release them after handshake. - // Ideally we release them as soon as handshake is done. - - // We need a way to ensure release happens exactly once. released := false release := func() { if released { @@ -112,16 +122,6 @@ func (l *SSHListener) Serve() { // Release global semaphore <-l.handshakeLimit - - // Release per-IP limit - if host != "" { - l.connLimitMutex.Lock() - l.connLimit[host]-- - if l.connLimit[host] == 0 { - delete(l.connLimit, host) - } - l.connLimitMutex.Unlock() - } } // Defer release in case of panic or early return From 4a6d84d4393730d2ce7acaa23492c8762be632cb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:36:19 +0000 Subject: [PATCH 4/6] Mitigate DoS by limiting concurrent handshakes This commit introduces mechanism to limit the number of concurrent SSH handshakes to prevent resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack. 1. **Global Limit:** A semaphore limits total concurrent handshakes to 20. This applies backpressure to the `Accept` loop. 2. **Per-IP Limit:** Using `github.com/sethvargo/go-limiter`, we now limit the rate of connection attempts per IP to 3 per second. 3. **Timeout:** The initial handshake timeout is reduced from 20s to 10s. The global semaphore is acquired before the handshake and released immediately after the handshake completes (or fails), ensuring that long-lived sessions do not consume a handshake slot. --- sshd/net.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sshd/net.go b/sshd/net.go index 677a1b89..28f2c31a 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -130,6 +130,9 @@ func (l *SSHListener) Serve() { term, err := l.handleConn(conn) // Handshake is done (success or failure). Release limits. + // Explicit release is required because l.HandlerFunc below + // runs for the duration of the session. We only want to limit + // concurrent handshakes, not concurrent sessions. release() if err != nil { From d81fd3a449b426e02bab521ecc60497ccd20ad5b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:56:02 +0000 Subject: [PATCH 5/6] Mitigate DoS by limiting concurrent handshakes This commit introduces mechanism to limit the number of concurrent SSH handshakes to prevent resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack. 1. **Global Limit:** A semaphore limits total concurrent handshakes to 20. This applies backpressure to the `Accept` loop. 2. **Per-IP Limit:** Using `github.com/sethvargo/go-limiter`, we now limit the rate of connection attempts per IP to 3 per second. 3. **Timeout:** The initial handshake timeout is reduced from 20s to 10s. The global semaphore is acquired before the handshake and released immediately after the handshake completes (or fails), ensuring that long-lived sessions do not consume a handshake slot. --- sshd/net.go | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/sshd/net.go b/sshd/net.go index 28f2c31a..6b67b67a 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -111,29 +111,13 @@ func (l *SSHListener) Serve() { // Goroutineify to resume accepting sockets early go func() { - // Ensure limits are released when this goroutine finishes (in case of panic) - // OR explicitly release them after handshake. - released := false - release := func() { - if released { - return - } - released = true - - // Release global semaphore - <-l.handshakeLimit - } - - // Defer release in case of panic or early return - defer release() - term, err := l.handleConn(conn) // Handshake is done (success or failure). Release limits. // Explicit release is required because l.HandlerFunc below // runs for the duration of the session. We only want to limit // concurrent handshakes, not concurrent sessions. - release() + <-l.handshakeLimit if err != nil { logger.Printf("[%s] Failed to handshake: %s", conn.RemoteAddr(), err) From 45d1bae2fdaf93b2631517c9f3e3a5ac511dee5b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:00:37 +0000 Subject: [PATCH 6/6] Mitigate DoS by limiting concurrent handshakes This commit introduces mechanism to limit the number of concurrent SSH handshakes to prevent resource exhaustion (goroutines/memory) during a SYN flood or connection flood attack. 1. **Global Limit:** A semaphore limits total concurrent handshakes to 20. This applies backpressure to the `Accept` loop. 2. **Per-IP Limit:** Using `github.com/sethvargo/go-limiter`, we now limit the rate of connection attempts per IP to 3 per second. 3. **Timeout:** The initial handshake timeout is reduced from 20s to 10s. The global semaphore is acquired before the handshake and released immediately after the handshake completes (or fails), ensuring that long-lived sessions do not consume a handshake slot. --- sshd/net.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sshd/net.go b/sshd/net.go index 6b67b67a..6723f775 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -92,7 +92,9 @@ func (l *SSHListener) Serve() { host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) if err != nil { logger.Printf("Failed to split remote addr: %v", err) - } else { + } + + if err == nil { // Context with timeout is not strictly needed for memory store, but good practice // Although Take is non-blocking for memory store usually. _, _, _, ok, err := l.limiter.Take(context.Background(), host)