Skip to content

Commit 6235737

Browse files
CopilotBios-Marcel
andcommitted
Add rate limiting for chat spam protection with tests
Co-authored-by: Bios-Marcel <[email protected]>
1 parent d45b8e0 commit 6235737

File tree

3 files changed

+224
-11
lines changed

3 files changed

+224
-11
lines changed

internal/game/lobby.go

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,56 @@ func (lobby *Lobby) readyToStart() bool {
234234
return hasConnectedPlayers
235235
}
236236

237+
const (
238+
// Rate limiting constants
239+
// Allow up to 5 messages per second
240+
maxMessagesPerSecond = 5
241+
// Allow up to 30 messages in 20 seconds
242+
maxMessagesInWindow = 30
243+
rateLimitWindowSeconds = 20
244+
)
245+
246+
// isRateLimited checks if a player has exceeded rate limits.
247+
// Rate limits: 5 messages/second and 30 messages in 20 seconds.
248+
func isRateLimited(player *Player) bool {
249+
now := time.Now()
250+
251+
// Clean up old timestamps (older than 20 seconds)
252+
cutoff := now.Add(-rateLimitWindowSeconds * time.Second)
253+
validTimestamps := make([]time.Time, 0, len(player.messageTimestamps))
254+
for _, ts := range player.messageTimestamps {
255+
if ts.After(cutoff) {
256+
validTimestamps = append(validTimestamps, ts)
257+
}
258+
}
259+
player.messageTimestamps = validTimestamps
260+
261+
// Check if exceeded 30 messages in 20 seconds window
262+
if len(player.messageTimestamps) >= maxMessagesInWindow {
263+
return true
264+
}
265+
266+
// Check if exceeded 5 messages in the last second
267+
oneSecondAgo := now.Add(-1 * time.Second)
268+
messagesInLastSecond := 0
269+
for _, ts := range player.messageTimestamps {
270+
if ts.After(oneSecondAgo) {
271+
messagesInLastSecond++
272+
}
273+
}
274+
275+
if messagesInLastSecond >= maxMessagesPerSecond {
276+
return true
277+
}
278+
279+
return false
280+
}
281+
282+
// recordMessage adds the current timestamp to the player's message history.
283+
func recordMessage(player *Player) {
284+
player.messageTimestamps = append(player.messageTimestamps, time.Now())
285+
}
286+
237287
func handleMessage(message string, sender *Player, lobby *Lobby) {
238288
// Very long message can cause lags and can therefore be easily abused.
239289
// While it is debatable whether a 10000 byte (not character) long
@@ -250,18 +300,33 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
250300
return
251301
}
252302

303+
// Check if player is rate limited
304+
rateLimited := isRateLimited(sender)
305+
// Record message timestamp for rate limiting (even if rate limited)
306+
recordMessage(sender)
307+
253308
// If no word is currently selected, all players can talk to each other
254309
// and we don't have to check for corrected guesses.
255310
if lobby.CurrentWord == "" {
256-
lobby.broadcastMessage(trimmedMessage, sender)
311+
if rateLimited {
312+
// Silent rate limiting: send message only to sender
313+
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
314+
} else {
315+
lobby.broadcastMessage(trimmedMessage, sender)
316+
}
257317
return
258318
}
259319

260320
if sender.State != Guessing {
261-
lobby.broadcastConditional(
262-
newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender),
263-
IsAllowedToSeeRevealedHints,
264-
)
321+
if rateLimited {
322+
// Silent rate limiting: send message only to sender
323+
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender))
324+
} else {
325+
lobby.broadcastConditional(
326+
newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender),
327+
IsAllowedToSeeRevealedHints,
328+
)
329+
}
265330
return
266331
}
267332

@@ -271,6 +336,12 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
271336
switch CheckGuess(normInput, normSearched) {
272337
case EqualGuess:
273338
{
339+
// Don't process correct guesses if rate limited (prevents guess botting)
340+
if rateLimited {
341+
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
342+
return
343+
}
344+
274345
sender.LastScore = lobby.calculateGuesserScore()
275346
sender.Score += sender.LastScore
276347

@@ -289,14 +360,25 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
289360
}
290361
case CloseGuess:
291362
{
292-
// In cases of a close guess, we still send the message to everyone.
293-
// This allows other players to guess the word by watching what the
294-
// other players are misstyping.
295-
lobby.broadcastMessage(trimmedMessage, sender)
296-
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
363+
if rateLimited {
364+
// Silent rate limiting: send only to sender
365+
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
366+
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
367+
} else {
368+
// In cases of a close guess, we still send the message to everyone.
369+
// This allows other players to guess the word by watching what the
370+
// other players are misstyping.
371+
lobby.broadcastMessage(trimmedMessage, sender)
372+
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
373+
}
297374
}
298375
default:
299-
lobby.broadcastMessage(trimmedMessage, sender)
376+
if rateLimited {
377+
// Silent rate limiting: send message only to sender
378+
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
379+
} else {
380+
lobby.broadcastMessage(trimmedMessage, sender)
381+
}
300382
}
301383
}
302384

internal/game/lobby_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,131 @@ func Test_NoPrematureGameOver(t *testing.T) {
553553
require.Equal(t, Standby, player.State)
554554
require.Equal(t, Unstarted, lobby.State)
555555
}
556+
557+
func Test_RateLimiting_NoLimit(t *testing.T) {
558+
t.Parallel()
559+
560+
player := &Player{
561+
messageTimestamps: []time.Time{},
562+
}
563+
564+
// No messages yet, should not be rate limited
565+
require.False(t, isRateLimited(player))
566+
}
567+
568+
func Test_RateLimiting_UnderLimit(t *testing.T) {
569+
t.Parallel()
570+
571+
player := &Player{
572+
messageTimestamps: []time.Time{},
573+
}
574+
575+
now := time.Now()
576+
577+
// Add 4 messages in the last second (under the 5/second limit)
578+
for i := 0; i < 4; i++ {
579+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*100)*time.Millisecond))
580+
}
581+
582+
require.False(t, isRateLimited(player))
583+
}
584+
585+
func Test_RateLimiting_ExceedPerSecondLimit(t *testing.T) {
586+
t.Parallel()
587+
588+
player := &Player{
589+
messageTimestamps: []time.Time{},
590+
}
591+
592+
now := time.Now()
593+
594+
// Add 5 messages in the last second (at the 5/second limit)
595+
for i := 0; i < 5; i++ {
596+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*100)*time.Millisecond))
597+
}
598+
599+
// Should be rate limited (5 messages already, trying to send 6th)
600+
require.True(t, isRateLimited(player))
601+
}
602+
603+
func Test_RateLimiting_ExceedWindowLimit(t *testing.T) {
604+
t.Parallel()
605+
606+
player := &Player{
607+
messageTimestamps: []time.Time{},
608+
}
609+
610+
now := time.Now()
611+
612+
// Add 30 messages spread over 19 seconds (at the 30/20s limit)
613+
for i := 0; i < 30; i++ {
614+
// Spread messages across 19 seconds, not exceeding 5/second
615+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*600)*time.Millisecond))
616+
}
617+
618+
// Should be rate limited (30 messages already in window)
619+
require.True(t, isRateLimited(player))
620+
}
621+
622+
func Test_RateLimiting_OldTimestampsCleanup(t *testing.T) {
623+
t.Parallel()
624+
625+
player := &Player{
626+
messageTimestamps: []time.Time{},
627+
}
628+
629+
now := time.Now()
630+
631+
// Add 30 messages older than 20 seconds
632+
for i := 0; i < 30; i++ {
633+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-21*time.Second))
634+
}
635+
636+
// Should not be rate limited (all timestamps are old and should be cleaned up)
637+
require.False(t, isRateLimited(player))
638+
639+
// Verify old timestamps were cleaned up
640+
require.Equal(t, 0, len(player.messageTimestamps))
641+
}
642+
643+
func Test_RateLimiting_RecordMessage(t *testing.T) {
644+
t.Parallel()
645+
646+
player := &Player{
647+
messageTimestamps: []time.Time{},
648+
}
649+
650+
require.Equal(t, 0, len(player.messageTimestamps))
651+
652+
recordMessage(player)
653+
require.Equal(t, 1, len(player.messageTimestamps))
654+
655+
recordMessage(player)
656+
require.Equal(t, 2, len(player.messageTimestamps))
657+
}
658+
659+
func Test_RateLimiting_MixedOldAndNewMessages(t *testing.T) {
660+
t.Parallel()
661+
662+
player := &Player{
663+
messageTimestamps: []time.Time{},
664+
}
665+
666+
now := time.Now()
667+
668+
// Add 15 old messages (older than 20 seconds)
669+
for i := 0; i < 15; i++ {
670+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-21*time.Second))
671+
}
672+
673+
// Add 4 recent messages (under the 5/second limit)
674+
for i := 0; i < 4; i++ {
675+
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*200)*time.Millisecond))
676+
}
677+
678+
// Should not be rate limited (old messages cleaned up, only 4 recent)
679+
require.False(t, isRateLimited(player))
680+
681+
// Verify cleanup happened
682+
require.Equal(t, 4, len(player.messageTimestamps))
683+
}

internal/game/shared.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ type Player struct {
214214
disconnectTime *time.Time
215215
votedForKick map[uuid.UUID]bool
216216
lastKnownAddress string
217+
// messageTimestamps tracks the timestamps of recent messages for rate limiting.
218+
// Stores up to 30 timestamps (max messages in 20 seconds).
219+
messageTimestamps []time.Time
217220

218221
// Name is the players displayed name
219222
Name string `json:"name"`

0 commit comments

Comments
 (0)