Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 101 additions & 11 deletions internal/game/lobby.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,64 @@ func (lobby *Lobby) readyToStart() bool {
return hasConnectedPlayers
}

const (
// Rate limiting constants
// Allow up to 5 messages per second
maxMessagesPerSecond = 5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 per second makes sense, thats roughly 150 wpm

// Allow up to 30 messages in 20 seconds
maxMessagesInWindow = 30
rateLimitWindowSeconds = 20
)

// isRateLimited checks if a player has exceeded rate limits.
// Rate limits: 5 messages/second and 30 messages in 20 seconds.
func isRateLimited(player *Player) bool {
now := time.Now()

// Clean up old timestamps (older than 20 seconds) in-place
cutoff := now.Add(-rateLimitWindowSeconds * time.Second)
validCount := 0
for i, ts := range player.messageTimestamps {
if ts.After(cutoff) {
// Move valid timestamp to the front of the slice
player.messageTimestamps[validCount] = player.messageTimestamps[i]
validCount++
}
}
// Trim slice to keep only valid timestamps
player.messageTimestamps = player.messageTimestamps[:validCount]

// Check if exceeded 30 messages in 20 seconds window
if len(player.messageTimestamps) >= maxMessagesInWindow {
return true
}

// Check if exceeded 5 messages in the last second
// Since timestamps are chronologically ordered (newest last),
// we iterate backwards and break early when we find an old timestamp
oneSecondAgo := now.Add(-1 * time.Second)
messagesInLastSecond := 0
for i := len(player.messageTimestamps) - 1; i >= 0; i-- {
if player.messageTimestamps[i].After(oneSecondAgo) {
messagesInLastSecond++
} else {
// Timestamps are chronologically ordered, so we can break early
break
}
}

if messagesInLastSecond >= maxMessagesPerSecond {
return true
}

return false
}

// recordMessage adds the current timestamp to the player's message history.
func recordMessage(player *Player) {
player.messageTimestamps = append(player.messageTimestamps, time.Now())
}

func handleMessage(message string, sender *Player, lobby *Lobby) {
// Very long message can cause lags and can therefore be easily abused.
// While it is debatable whether a 10000 byte (not character) long
Expand All @@ -250,18 +308,33 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
return
}

// Check if player is rate limited
rateLimited := isRateLimited(sender)
// Record message timestamp for rate limiting (even if rate limited)
recordMessage(sender)

// If no word is currently selected, all players can talk to each other
// and we don't have to check for corrected guesses.
if lobby.CurrentWord == "" {
lobby.broadcastMessage(trimmedMessage, sender)
if rateLimited {
// Silent rate limiting: send message only to sender
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
} else {
lobby.broadcastMessage(trimmedMessage, sender)
}
return
}

if sender.State != Guessing {
lobby.broadcastConditional(
newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender),
IsAllowedToSeeRevealedHints,
)
if rateLimited {
// Silent rate limiting: send message only to sender
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender))
} else {
lobby.broadcastConditional(
newMessageEvent(EventTypeNonGuessingPlayerMessage, trimmedMessage, sender),
IsAllowedToSeeRevealedHints,
)
}
return
}

Expand All @@ -271,6 +344,12 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
switch CheckGuess(normInput, normSearched) {
case EqualGuess:
{
// Don't process correct guesses if rate limited (prevents guess botting)
if rateLimited {
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
return
}

sender.LastScore = lobby.calculateGuesserScore()
sender.Score += sender.LastScore

Expand All @@ -289,14 +368,25 @@ func handleMessage(message string, sender *Player, lobby *Lobby) {
}
case CloseGuess:
{
// In cases of a close guess, we still send the message to everyone.
// This allows other players to guess the word by watching what the
// other players are misstyping.
lobby.broadcastMessage(trimmedMessage, sender)
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
if rateLimited {
// Silent rate limiting: send only to sender
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
} else {
// In cases of a close guess, we still send the message to everyone.
// This allows other players to guess the word by watching what the
// other players are misstyping.
lobby.broadcastMessage(trimmedMessage, sender)
_ = lobby.WriteObject(sender, Event{Type: EventTypeCloseGuess, Data: trimmedMessage})
}
}
default:
lobby.broadcastMessage(trimmedMessage, sender)
if rateLimited {
// Silent rate limiting: send message only to sender
_ = lobby.WriteObject(sender, newMessageEvent(EventTypeMessage, trimmedMessage, sender))
} else {
lobby.broadcastMessage(trimmedMessage, sender)
}
}
}

Expand Down
128 changes: 128 additions & 0 deletions internal/game/lobby_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,131 @@ func Test_NoPrematureGameOver(t *testing.T) {
require.Equal(t, Standby, player.State)
require.Equal(t, Unstarted, lobby.State)
}

func Test_RateLimiting_NoLimit(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

// No messages yet, should not be rate limited
require.False(t, isRateLimited(player))
}

func Test_RateLimiting_UnderLimit(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

now := time.Now()

// Add 4 messages in the last second (under the 5/second limit)
for i := 0; i < 4; i++ {
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*100)*time.Millisecond))
}

require.False(t, isRateLimited(player))
}

func Test_RateLimiting_ExceedPerSecondLimit(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

now := time.Now()

// Add 5 messages in the last second (at the 5/second limit)
for i := 0; i < 5; i++ {
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*100)*time.Millisecond))
}

// Should be rate limited (5 messages already, trying to send 6th)
require.True(t, isRateLimited(player))
}

func Test_RateLimiting_ExceedWindowLimit(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

now := time.Now()

// Add 30 messages spread over 19 seconds (at the 30/20s limit)
for i := 0; i < 30; i++ {
// Spread messages across 19 seconds, not exceeding 5/second
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*600)*time.Millisecond))
}

// Should be rate limited (30 messages already in window)
require.True(t, isRateLimited(player))
}

func Test_RateLimiting_OldTimestampsCleanup(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

now := time.Now()

// Add 30 messages older than 20 seconds
for i := 0; i < 30; i++ {
player.messageTimestamps = append(player.messageTimestamps, now.Add(-21*time.Second))
}

// Should not be rate limited (all timestamps are old and should be cleaned up)
require.False(t, isRateLimited(player))

// Verify old timestamps were cleaned up
require.Equal(t, 0, len(player.messageTimestamps))
}

func Test_RateLimiting_RecordMessage(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

require.Equal(t, 0, len(player.messageTimestamps))

recordMessage(player)
require.Equal(t, 1, len(player.messageTimestamps))

recordMessage(player)
require.Equal(t, 2, len(player.messageTimestamps))
}

func Test_RateLimiting_MixedOldAndNewMessages(t *testing.T) {
t.Parallel()

player := &Player{
messageTimestamps: []time.Time{},
}

now := time.Now()

// Add 15 old messages (older than 20 seconds)
for i := 0; i < 15; i++ {
player.messageTimestamps = append(player.messageTimestamps, now.Add(-21*time.Second))
}

// Add 4 recent messages (under the 5/second limit)
for i := 0; i < 4; i++ {
player.messageTimestamps = append(player.messageTimestamps, now.Add(-time.Duration(i*200)*time.Millisecond))
}

// Should not be rate limited (old messages cleaned up, only 4 recent)
require.False(t, isRateLimited(player))

// Verify cleanup happened
require.Equal(t, 4, len(player.messageTimestamps))
}
3 changes: 3 additions & 0 deletions internal/game/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ type Player struct {
disconnectTime *time.Time
votedForKick map[uuid.UUID]bool
lastKnownAddress string
// messageTimestamps tracks the timestamps of recent messages for rate limiting.
// Stores up to 30 timestamps (max messages in 20 seconds).
messageTimestamps []time.Time

// Name is the players displayed name
Name string `json:"name"`
Expand Down