strat-gameplay-webapp/.claude/plans/006-rate-limiting.md
Cal Corum e0c12467b0 CLAUDE: Improve UX with single-click OAuth, enhanced games list, and layout fix
Frontend UX improvements:
- Single-click Discord OAuth from home page (no intermediate /auth page)
- Auto-redirect authenticated users from home to /games
- Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout
- Games page now has proper card container with shadow/border styling
- Layout header includes working logout with API cookie clearing

Games list enhancements:
- Display team names (lname) instead of just team IDs
- Show current score for each team
- Show inning indicator (Top/Bot X) for active games
- Responsive header with wrapped buttons on mobile

Backend improvements:
- Added team caching to SbaApiClient (1-hour TTL)
- Enhanced GameListItem with team names, scores, inning data
- Games endpoint now enriches response with SBA API team data

Docker optimizations:
- Optimized Dockerfile using --chown flag on COPY (faster than chown -R)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:14:00 -06:00

452 lines
13 KiB
Markdown

# Plan 006: Add Rate Limiting
**Priority**: HIGH
**Effort**: 2-3 hours
**Status**: NOT STARTED
**Risk Level**: MEDIUM - DoS vulnerability
---
## Problem Statement
No rate limiting exists on WebSocket events or REST API endpoints. A malicious or buggy client can:
- Spam decision submissions
- Flood dice roll requests
- Overwhelm the server with requests
- Cause denial of service
## Impact
- **Availability**: Server can be overwhelmed
- **Fairness**: Spammers can disrupt games
- **Cost**: Excessive resource usage
## Files to Modify/Create
| File | Action |
|------|--------|
| `backend/app/middleware/rate_limit.py` | Create rate limiter |
| `backend/app/websocket/handlers.py` | Add rate limit checks |
| `backend/app/api/routes.py` | Add rate limit decorator |
| `backend/app/config.py` | Add rate limit settings |
## Implementation Steps
### Step 1: Add Configuration (10 min)
Update `backend/app/config.py`:
```python
class Settings(BaseSettings):
# ... existing settings ...
# Rate limiting
rate_limit_websocket_per_minute: int = 60 # Events per minute per connection
rate_limit_api_per_minute: int = 100 # API calls per minute per user
rate_limit_decision_per_game: int = 10 # Decisions per minute per game
rate_limit_roll_per_game: int = 20 # Rolls per minute per game
```
### Step 2: Create Rate Limiter (45 min)
Create `backend/app/middleware/rate_limit.py`:
```python
"""Rate limiting utilities for WebSocket and API endpoints."""
import asyncio
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Callable
import logging
from app.config import settings
logger = logging.getLogger(f"{__name__}.RateLimiter")
@dataclass
class RateLimitBucket:
"""Token bucket for rate limiting."""
tokens: int
max_tokens: int
refill_rate: float # tokens per second
last_refill: datetime = field(default_factory=datetime.utcnow)
def consume(self, tokens: int = 1) -> bool:
"""
Try to consume tokens. Returns True if allowed, False if rate limited.
"""
self._refill()
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
def _refill(self):
"""Refill tokens based on time elapsed."""
now = datetime.utcnow()
elapsed = (now - self.last_refill).total_seconds()
refill_amount = int(elapsed * self.refill_rate)
if refill_amount > 0:
self.tokens = min(self.max_tokens, self.tokens + refill_amount)
self.last_refill = now
class RateLimiter:
"""
Rate limiter for WebSocket connections and API endpoints.
Uses token bucket algorithm for smooth rate limiting.
"""
def __init__(self):
# Per-connection buckets
self._connection_buckets: dict[str, RateLimitBucket] = {}
# Per-game buckets (for game-specific limits)
self._game_buckets: dict[str, RateLimitBucket] = {}
# Per-user API buckets
self._user_buckets: dict[int, RateLimitBucket] = {}
# Cleanup task
self._cleanup_task: asyncio.Task | None = None
def get_connection_bucket(self, sid: str) -> RateLimitBucket:
"""Get or create bucket for WebSocket connection."""
if sid not in self._connection_buckets:
self._connection_buckets[sid] = RateLimitBucket(
tokens=settings.rate_limit_websocket_per_minute,
max_tokens=settings.rate_limit_websocket_per_minute,
refill_rate=settings.rate_limit_websocket_per_minute / 60
)
return self._connection_buckets[sid]
def get_game_bucket(self, game_id: str, action: str) -> RateLimitBucket:
"""Get or create bucket for game-specific action."""
key = f"{game_id}:{action}"
if key not in self._game_buckets:
if action == "decision":
limit = settings.rate_limit_decision_per_game
elif action == "roll":
limit = settings.rate_limit_roll_per_game
else:
limit = 30 # Default
self._game_buckets[key] = RateLimitBucket(
tokens=limit,
max_tokens=limit,
refill_rate=limit / 60
)
return self._game_buckets[key]
def get_user_bucket(self, user_id: int) -> RateLimitBucket:
"""Get or create bucket for API user."""
if user_id not in self._user_buckets:
self._user_buckets[user_id] = RateLimitBucket(
tokens=settings.rate_limit_api_per_minute,
max_tokens=settings.rate_limit_api_per_minute,
refill_rate=settings.rate_limit_api_per_minute / 60
)
return self._user_buckets[user_id]
async def check_websocket_limit(self, sid: str) -> bool:
"""Check if WebSocket event is allowed."""
bucket = self.get_connection_bucket(sid)
allowed = bucket.consume()
if not allowed:
logger.warning(f"Rate limited WebSocket connection: {sid}")
return allowed
async def check_game_limit(self, game_id: str, action: str) -> bool:
"""Check if game action is allowed."""
bucket = self.get_game_bucket(game_id, action)
allowed = bucket.consume()
if not allowed:
logger.warning(f"Rate limited game action: {game_id} {action}")
return allowed
async def check_api_limit(self, user_id: int) -> bool:
"""Check if API call is allowed."""
bucket = self.get_user_bucket(user_id)
allowed = bucket.consume()
if not allowed:
logger.warning(f"Rate limited API user: {user_id}")
return allowed
def remove_connection(self, sid: str):
"""Clean up when connection closes."""
self._connection_buckets.pop(sid, None)
async def cleanup_stale_buckets(self):
"""Periodically clean up stale buckets."""
while True:
await asyncio.sleep(300) # Every 5 minutes
now = datetime.utcnow()
stale_threshold = timedelta(minutes=10)
# Clean connection buckets
stale_connections = [
sid for sid, bucket in self._connection_buckets.items()
if now - bucket.last_refill > stale_threshold
]
for sid in stale_connections:
del self._connection_buckets[sid]
# Clean game buckets
stale_games = [
key for key, bucket in self._game_buckets.items()
if now - bucket.last_refill > stale_threshold
]
for key in stale_games:
del self._game_buckets[key]
logger.debug(f"Cleaned {len(stale_connections)} connection, {len(stale_games)} game buckets")
# Global rate limiter instance
rate_limiter = RateLimiter()
```
### Step 3: Create Decorator for Handlers (20 min)
Add to `backend/app/middleware/rate_limit.py`:
```python
from functools import wraps
def rate_limited(action: str = "general"):
"""
Decorator for rate-limited WebSocket handlers.
Usage:
@sio.event
@rate_limited(action="decision")
async def submit_defensive_decision(sid, data):
...
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(sid, data, *args, **kwargs):
# Check connection-level limit
if not await rate_limiter.check_websocket_limit(sid):
await sio.emit("error", {
"message": "Rate limited. Please slow down.",
"code": "RATE_LIMITED"
}, to=sid)
return
# Check game-level limit if game_id in data
game_id = data.get("game_id") if isinstance(data, dict) else None
if game_id and action != "general":
if not await rate_limiter.check_game_limit(str(game_id), action):
await sio.emit("error", {
"message": f"Too many {action} requests for this game.",
"code": "GAME_RATE_LIMITED"
}, to=sid)
return
return await func(sid, data, *args, **kwargs)
return wrapper
return decorator
```
### Step 4: Apply to WebSocket Handlers (30 min)
Update `backend/app/websocket/handlers.py`:
```python
from app.middleware.rate_limit import rate_limited, rate_limiter
@sio.event
async def connect(sid, environ, auth):
# ... existing logic ...
pass
@sio.event
async def disconnect(sid):
# Clean up rate limiter
rate_limiter.remove_connection(sid)
# ... existing logic ...
@sio.event
@rate_limited(action="decision")
async def submit_defensive_decision(sid, data):
# ... existing logic (rate limiting handled by decorator) ...
pass
@sio.event
@rate_limited(action="decision")
async def submit_offensive_decision(sid, data):
# ... existing logic ...
pass
@sio.event
@rate_limited(action="roll")
async def roll_dice(sid, data):
# ... existing logic ...
pass
@sio.event
@rate_limited(action="substitution")
async def request_pinch_hitter(sid, data):
# ... existing logic ...
pass
@sio.event
@rate_limited(action="substitution")
async def request_defensive_replacement(sid, data):
# ... existing logic ...
pass
@sio.event
@rate_limited(action="substitution")
async def request_pitching_change(sid, data):
# ... existing logic ...
pass
# Read-only handlers get general rate limit
@sio.event
@rate_limited()
async def get_lineup(sid, data):
# ... existing logic ...
pass
@sio.event
@rate_limited()
async def get_box_score(sid, data):
# ... existing logic ...
pass
```
### Step 5: Add API Rate Limiting (20 min)
Update `backend/app/api/routes.py`:
```python
from fastapi import Depends, HTTPException
from app.middleware.rate_limit import rate_limiter
async def check_rate_limit(user_id: int = Depends(get_current_user_id)):
"""Dependency for API rate limiting."""
if not await rate_limiter.check_api_limit(user_id):
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Please try again later."
)
return user_id
@router.post("/games", dependencies=[Depends(check_rate_limit)])
async def create_game(...):
# ... existing logic ...
pass
@router.get("/games/{game_id}", dependencies=[Depends(check_rate_limit)])
async def get_game(...):
# ... existing logic ...
pass
```
### Step 6: Start Cleanup Task (10 min)
Update `backend/app/main.py`:
```python
from app.middleware.rate_limit import rate_limiter
@asynccontextmanager
async def lifespan(app: FastAPI):
# Start rate limiter cleanup
cleanup_task = asyncio.create_task(rate_limiter.cleanup_stale_buckets())
yield
# Stop cleanup task
cleanup_task.cancel()
```
### Step 7: Write Tests (30 min)
Create `backend/tests/unit/middleware/test_rate_limit.py`:
```python
import pytest
from app.middleware.rate_limit import RateLimiter, RateLimitBucket
class TestRateLimiting:
"""Tests for rate limiting."""
def test_bucket_allows_under_limit(self):
"""Bucket allows requests under limit."""
bucket = RateLimitBucket(tokens=10, max_tokens=10, refill_rate=1)
assert bucket.consume() is True
assert bucket.tokens == 9
def test_bucket_denies_over_limit(self):
"""Bucket denies requests over limit."""
bucket = RateLimitBucket(tokens=1, max_tokens=10, refill_rate=0.1)
assert bucket.consume() is True
assert bucket.consume() is False
def test_bucket_refills_over_time(self):
"""Bucket refills tokens over time."""
bucket = RateLimitBucket(tokens=0, max_tokens=10, refill_rate=100)
# Simulate time passing
bucket.last_refill = bucket.last_refill.replace(
second=bucket.last_refill.second - 1
)
bucket._refill()
assert bucket.tokens > 0
@pytest.mark.asyncio
async def test_rate_limiter_tracks_connections(self):
"""Rate limiter tracks separate connections."""
limiter = RateLimiter()
# Different connections get different buckets
bucket1 = limiter.get_connection_bucket("sid1")
bucket2 = limiter.get_connection_bucket("sid2")
assert bucket1 is not bucket2
@pytest.mark.asyncio
async def test_rate_limiter_cleans_up_on_disconnect(self):
"""Rate limiter cleans up on disconnect."""
limiter = RateLimiter()
limiter.get_connection_bucket("sid1")
assert "sid1" in limiter._connection_buckets
limiter.remove_connection("sid1")
assert "sid1" not in limiter._connection_buckets
```
## Verification Checklist
- [ ] WebSocket events are rate limited
- [ ] Game-specific limits work (decisions, rolls)
- [ ] API endpoints are rate limited
- [ ] Rate limit errors return clear messages
- [ ] Cleanup removes stale buckets
- [ ] Tests pass
## Monitoring
After deployment, monitor:
- Rate limit hit frequency in logs
- Memory usage of rate limiter
- False positive rate (legitimate users blocked)
## Rollback Plan
If issues arise:
1. Increase rate limits in config
2. Disable decorator temporarily
3. Remove rate limit checks from handlers
## Dependencies
- None (can be implemented independently)
## Notes
- Consider Redis-backed rate limiting for horizontal scaling
- May want different limits for authenticated vs anonymous
- Future: Add configurable rate limits per user tier