Skip to content

Commit b4071b8

Browse files
Implement IP-based rate limiting for password attempts
Co-authored-by: erikdubbelboer <522870+erikdubbelboer@users.noreply.github.com>
1 parent 7560ce4 commit b4071b8

6 files changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
Feature: Rate limiting for password attempts
2+
3+
Background:
4+
Given the "signaling" backend is running
5+
6+
Scenario: Rate limiting blocks too many password attempts from same IP
7+
Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd"
8+
And "yellow" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd"
9+
And "blue" creates a lobby with these settings:
10+
"""json
11+
{
12+
"password": "foobar"
13+
}
14+
"""
15+
And "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7"
16+
17+
# Make several failed password attempts
18+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong1"
19+
Then "yellow" failed to join the lobby
20+
And the latest error for "yellow" is "invalid password"
21+
22+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong2"
23+
Then "yellow" failed to join the lobby
24+
And the latest error for "yellow" is "invalid password"
25+
26+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong3"
27+
Then "yellow" failed to join the lobby
28+
And the latest error for "yellow" is "invalid password"
29+
30+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong4"
31+
Then "yellow" failed to join the lobby
32+
And the latest error for "yellow" is "invalid password"
33+
34+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong5"
35+
Then "yellow" failed to join the lobby
36+
And the latest error for "yellow" is "invalid password"
37+
38+
# The 6th attempt should be rate limited
39+
When "yellow" tries to connect to the lobby "19yrzmetd2bn7" with the password "wrong6"
40+
Then "yellow" failed to join the lobby
41+
And the latest error for "yellow" is "too many password attempts"

internal/signaling/handler.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ func Handler(ctx context.Context, store stores.Store, cloudflare *cloudflare.Cre
2626
}
2727
go manager.Run(ctx)
2828

29+
// Create rate limiter for password attempts: 5 attempts per 15 minutes per IP
30+
passwordRateLimiter := util.NewPasswordRateLimiter(5, 15*time.Minute)
31+
2932
go func() {
3033
logger := logging.GetLogger(ctx)
3134
ticker := time.NewTicker(LobbyCleanInterval)
@@ -43,12 +46,24 @@ func Handler(ctx context.Context, store stores.Store, cloudflare *cloudflare.Cre
4346
}
4447
}()
4548

49+
// Close rate limiter when context is done
50+
go func() {
51+
<-ctx.Done()
52+
passwordRateLimiter.Close()
53+
}()
54+
4655
wg := &sync.WaitGroup{}
4756
return wg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4857
ctx := r.Context()
4958
logger := logging.GetLogger(ctx)
5059
logger.Debug("upgrading connection")
5160

61+
// Extract remote address for rate limiting
62+
remoteAddr := r.RemoteAddr
63+
if r.Header.Get("X-Forwarded-For") != "" {
64+
remoteAddr = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
65+
}
66+
5267
ctx, cancel := context.WithCancel(ctx)
5368
defer cancel()
5469

@@ -69,6 +84,8 @@ func Handler(ctx context.Context, store stores.Store, cloudflare *cloudflare.Cre
6984
conn: conn,
7085

7186
retrievedIDCallback: manager.Reconnected,
87+
rateLimiter: passwordRateLimiter,
88+
remoteAddr: remoteAddr,
7289
}
7390
defer func() {
7491
logger.Info("peer websocket closed", zap.String("peer", peer.ID), zap.String("game", peer.Game), zap.String("origin", r.Header.Get("Origin")))

internal/signaling/peer.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type Peer struct {
2626
closedPacketReceived bool
2727

2828
retrievedIDCallback func(context.Context, string, string, string) (bool, []string, error)
29+
rateLimiter *util.PasswordRateLimiter
30+
remoteAddr string
2931

3032
ID string
3133
Secret string
@@ -460,12 +462,27 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error {
460462
return fmt.Errorf("lobby code too long")
461463
}
462464

465+
// Check rate limit for password attempts if rate limiter is available
466+
if p.rateLimiter != nil {
467+
logger.Debug("checking rate limit", zap.String("remote_addr", p.remoteAddr))
468+
if !p.rateLimiter.IsAllowed(ctx, p.remoteAddr) {
469+
logger.Warn("rate limit exceeded for password attempt", zap.String("remote_addr", p.remoteAddr))
470+
util.ReplyError(ctx, p.conn, util.ErrorWithCode(fmt.Errorf("too many password attempts"), "rate-limited"))
471+
return nil
472+
}
473+
}
474+
463475
err := p.store.JoinLobby(ctx, p.Game, packet.Lobby, p.ID, packet.Password)
464476
if err != nil {
465477
if err == stores.ErrNotFound {
466478
util.ReplyError(ctx, p.conn, util.ErrorWithCode(err, "lobby-not-found"))
467479
return nil
468480
} else if err == stores.ErrInvalidPassword {
481+
// Record failed password attempt for rate limiting
482+
if p.rateLimiter != nil {
483+
logger.Debug("recording failed password attempt", zap.String("remote_addr", p.remoteAddr))
484+
p.rateLimiter.RecordFailedAttempt(ctx, p.remoteAddr)
485+
}
469486
util.ReplyError(ctx, p.conn, util.ErrorWithCode(err, "invalid-password"))
470487
return nil
471488
} else if err == stores.ErrLobbyIsFull {

internal/util/ratelimit.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package util
2+
3+
import (
4+
"context"
5+
"sync"
6+
"time"
7+
)
8+
9+
// GetRemoteAddr extracts the remote IP address from context
10+
// This duplicates the logic from metrics package to avoid circular imports
11+
func GetRemoteAddr(ctx context.Context) string {
12+
// Use the same context key pattern as metrics package
13+
type metricsContextKey int
14+
remoteAddrKey := metricsContextKey(1)
15+
16+
if addr, ok := ctx.Value(remoteAddrKey).(string); ok {
17+
return addr
18+
}
19+
return ""
20+
}
21+
22+
// PasswordRateLimiter provides simple IP-based rate limiting for password attempts
23+
type PasswordRateLimiter struct {
24+
mu sync.RWMutex
25+
attempts map[string][]time.Time
26+
maxAttempts int
27+
windowSize time.Duration
28+
cleanupTicker *time.Ticker
29+
done chan bool
30+
}
31+
32+
// NewPasswordRateLimiter creates a new rate limiter for password attempts
33+
// maxAttempts: maximum password attempts allowed per IP in the time window
34+
// windowSize: time window for counting attempts (e.g., 15 minutes)
35+
func NewPasswordRateLimiter(maxAttempts int, windowSize time.Duration) *PasswordRateLimiter {
36+
rl := &PasswordRateLimiter{
37+
attempts: make(map[string][]time.Time),
38+
maxAttempts: maxAttempts,
39+
windowSize: windowSize,
40+
done: make(chan bool),
41+
}
42+
43+
// Start cleanup routine to prevent memory leaks
44+
rl.cleanupTicker = time.NewTicker(windowSize)
45+
go rl.cleanup()
46+
47+
return rl
48+
}
49+
50+
// IsAllowed checks if a password attempt from the given IP is allowed
51+
// Returns true if attempt is allowed, false if rate limited
52+
func (rl *PasswordRateLimiter) IsAllowed(ctx context.Context, remoteAddr string) bool {
53+
if remoteAddr == "" {
54+
return true // Allow if we can't determine IP
55+
}
56+
57+
rl.mu.RLock()
58+
attempts, exists := rl.attempts[remoteAddr]
59+
rl.mu.RUnlock()
60+
61+
if !exists {
62+
return true
63+
}
64+
65+
now := time.Now()
66+
cutoff := now.Add(-rl.windowSize)
67+
68+
// Count valid attempts within window
69+
validAttempts := 0
70+
for _, attemptTime := range attempts {
71+
if attemptTime.After(cutoff) {
72+
validAttempts++
73+
}
74+
}
75+
76+
return validAttempts < rl.maxAttempts
77+
}
78+
79+
// RecordFailedAttempt records a failed password attempt for the given IP
80+
func (rl *PasswordRateLimiter) RecordFailedAttempt(ctx context.Context, remoteAddr string) {
81+
if remoteAddr == "" {
82+
return // Nothing to record if we can't determine IP
83+
}
84+
85+
rl.mu.Lock()
86+
defer rl.mu.Unlock()
87+
88+
now := time.Now()
89+
cutoff := now.Add(-rl.windowSize)
90+
91+
// Clean up old attempts for this IP and add new one
92+
attempts := rl.attempts[remoteAddr]
93+
validAttempts := make([]time.Time, 0, len(attempts)+1)
94+
95+
// Keep only recent attempts
96+
for _, attemptTime := range attempts {
97+
if attemptTime.After(cutoff) {
98+
validAttempts = append(validAttempts, attemptTime)
99+
}
100+
}
101+
102+
// Add current attempt
103+
validAttempts = append(validAttempts, now)
104+
rl.attempts[remoteAddr] = validAttempts
105+
}
106+
107+
// cleanup periodically removes old entries to prevent memory leaks
108+
func (rl *PasswordRateLimiter) cleanup() {
109+
for {
110+
select {
111+
case <-rl.cleanupTicker.C:
112+
rl.mu.Lock()
113+
now := time.Now()
114+
cutoff := now.Add(-rl.windowSize)
115+
116+
for ip, attempts := range rl.attempts {
117+
validAttempts := make([]time.Time, 0, len(attempts))
118+
for _, attemptTime := range attempts {
119+
if attemptTime.After(cutoff) {
120+
validAttempts = append(validAttempts, attemptTime)
121+
}
122+
}
123+
124+
if len(validAttempts) == 0 {
125+
delete(rl.attempts, ip)
126+
} else {
127+
rl.attempts[ip] = validAttempts
128+
}
129+
}
130+
rl.mu.Unlock()
131+
case <-rl.done:
132+
return
133+
}
134+
}
135+
}
136+
137+
// Close stops the cleanup routine
138+
func (rl *PasswordRateLimiter) Close() {
139+
if rl.cleanupTicker != nil {
140+
rl.cleanupTicker.Stop()
141+
}
142+
close(rl.done)
143+
}

0 commit comments

Comments
 (0)