Security: Add per-IP connection and login rate limiting
The TCP device port is exposed directly to the public internet, so a single host must not be able to exhaust the global connection budget, flood new connection attempts, or brute-force the device authorization passcode. - server: cap concurrent connections and the accept rate per source IP (defaults 256 concurrent / 120 per minute, overridable via YAMA_PERIP_MAX_CONNS and YAMA_PERIP_CONN_RATE). Rejections happen at accept time, before any buffer or goroutine is allocated, and a rejected device simply retries on its reconnect loop. - cmd: throttle TOKEN_AUTH per source IP (120/min) and answer over-limit attempts with a normal failed response so clients get a definitive reply. - web: periodically sweep the login rate-limit maps so the username-keyed one (which is attacker-controlled) cannot grow without bound. Per-IP defaults are generous enough that a NAT office whose devices share one source IP is unaffected. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
1 parent
2de21b2b6b
commit
8e0f1283c6
4 files changed
+344
-17
No files matched your search
@@ -0,0 +1,93 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIPLimiterConcurrentCap(t *testing.T) {
|
||||
l := newIPLimiter(2, 0, 0) // 2 concurrent, no rate limit
|
||||
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("first connection from an IP should be allowed")
|
||||
}
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("second connection from an IP should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("third concurrent connection from the same IP should be rejected")
|
||||
}
|
||||
|
||||
// A different IP is unaffected.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should not share the first IP's cap")
|
||||
}
|
||||
|
||||
// Releasing one slot frees capacity again.
|
||||
l.release("10.0.0.1")
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("after release, the IP should have a free slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterRateLimit(t *testing.T) {
|
||||
l := newIPLimiter(0, 3, time.Minute) // no concurrent cap, 3/min per IP
|
||||
|
||||
for range 3 {
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("an attempt within budget should be allowed")
|
||||
}
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("4th connection within the window should be rejected")
|
||||
}
|
||||
// A different IP has its own budget.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should have its own rate budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterEmptyIPBypasses(t *testing.T) {
|
||||
l := newIPLimiter(1, 1, time.Minute)
|
||||
// An empty peer IP (shouldn't happen for real TCP) must not be throttled,
|
||||
// so a caller that loses the IP falls back to accepting.
|
||||
if !l.allow("") {
|
||||
t.Fatal("empty IP should bypass the limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterSweep(t *testing.T) {
|
||||
l := newIPLimiter(0, 1, time.Minute)
|
||||
|
||||
l.mu.Lock()
|
||||
l.recent["stale"] = []time.Time{time.Now().Add(-time.Hour)}
|
||||
l.recent["fresh"] = []time.Time{time.Now()}
|
||||
l.mu.Unlock()
|
||||
|
||||
l.sweep()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.recent["stale"]; ok {
|
||||
t.Fatal("stale rate-limit entry should be swept")
|
||||
}
|
||||
if _, ok := l.recent["fresh"]; !ok {
|
||||
t.Fatal("fresh rate-limit entry should survive sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr net.Addr
|
||||
want string
|
||||
}{
|
||||
{&net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}, "1.2.3.4"},
|
||||
{&net.TCPAddr{IP: net.ParseIP("::1"), Port: 6543}, "::1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := peerIP(tt.addr); got != tt.want {
|
||||
t.Fatalf("peerIP(%v) = %q, want %q", tt.addr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user