Security: Cap per-connection inbound buffer to prevent memory DoS

The protocol length field is validated to 50 MiB, but a peer that sends
bytes which never assemble into a complete packet — for example an HTTP-mask
envelope without its terminating CRLF CRLF, which Parse() waits on
indefinitely — could grow the per-connection input buffer without bound.
handleConnection now closes the connection once unparsed buffered bytes
exceed MaxBufferedBytes (default 50 MiB + 64 KiB slack, covering the largest
legitimate packet plus its mask envelope and a read chunk).

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxianganddeepseek-v4-pro committed 2026-10-13 16:27:20 +02:00
1 parent 8e0f1283c6
commit 870a5832ea
2 files changed
+58

No files matched your search

+19
View File
@@ -23,6 +23,15 @@ type Config struct {
ReadTimeout time.Duration
WriteTimeout time.Duration
// MaxBufferedBytes is a hard cap on unparsed inbound bytes buffered per
// connection. The protocol's own length field is validated to 50 MiB, but
// a peer that sends bytes that never assemble into a complete packet (e.g.
// an HTTP-mask envelope without its terminating CRLF CRLF, which Parse()
// waits on indefinitely) could otherwise grow the buffer without bound.
// Default leaves slack for the largest legitimate packet plus its mask
// envelope and a read chunk.
MaxBufferedBytes int
// Per-IP connection limits. The TCP listener (port 6543) is exposed
// directly to the public internet, so a single host must not be able to
// exhaust the global connection budget or flood new-connection attempts.
@@ -43,6 +52,7 @@ func DefaultConfig() Config {
KeepAliveTime: time.Minute * 5,
ReadTimeout: time.Minute * 2,
WriteTimeout: time.Second * 30,
MaxBufferedBytes: 50*1024*1024 + 64*1024,
PerIPMaxConnections: 256,
PerIPConnRateLimit: 120,
PerIPConnRateWindow: time.Minute,
@@ -399,6 +409,15 @@ func (s *Server) handleConnection(conn net.Conn) {
// Write to input buffer
_, _ = ctx.InBuffer.Write(buf[:n])
// Hard cap on unparsed inbound bytes (memory-DoS guard). A peer
// that sends bytes that never assemble into a complete packet can
// otherwise grow this buffer without bound.
if ctx.InBuffer.Len() > s.config.MaxBufferedBytes {
s.logger.Warn("Inbound buffer exceeded %d bytes from ip=%s conn=%d — closing",
s.config.MaxBufferedBytes, ctx.GetPeerIP(), ctx.ID)
break
}
// Process received data
s.processData(ctx)
}
+39
View File
@@ -1,6 +1,7 @@
package server
import (
"bytes"
"net"
"testing"
"time"
@@ -91,3 +92,41 @@ func TestPeerIP(t *testing.T) {
}
}
}
// A peer that sends bytes which never assemble into a complete packet (here an
// HTTP-mask envelope with no terminating CRLF CRLF) must be disconnected once
// the per-connection buffer cap is exceeded, rather than allowed to grow it
// without bound.
func TestHandleConnectionBufferCap(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxBufferedBytes = 1024 // small cap for a fast, in-memory test
cfg.ReadTimeout = time.Second
srv := New(cfg)
srv.running.Store(true) // bypass acceptLoop; exercise handleConnection directly
client, serverConn := net.Pipe()
defer client.Close()
done := make(chan struct{})
go func() {
srv.handleConnection(serverConn)
close(done)
}()
// "POST " prefix with no terminator, plus filler to exceed the cap.
payload := append([]byte("POST /x HTTP/1.1\r\n"), bytes.Repeat([]byte{'A'}, 4096)...)
writeErr := make(chan error, 1)
go func() {
_, err := client.Write(payload)
writeErr <- err
}()
select {
case <-done:
// handleConnection returned because the cap was exceeded and it closed
// the connection.
case <-time.After(2 * time.Second):
t.Fatal("connection was not closed after exceeding the buffer cap")
}
}