feat(chat): implement spam protection and profanity filter

Add comprehensive message validation with three protection mechanisms:

1. Rate Limiting: 10 messages per minute per user
2. Duplicate Detection: Prevents sending identical messages within 1 minute
3. Profanity Filter: Blocks inappropriate language (English + Polish)

Implementation:
- New messageValidation.js middleware with validateMessage() function
- Integrated into both event chat and match chat handlers
- Uses bad-words library (v2.0.0 for CommonJS compatibility)
- In-memory tracking with automatic cleanup every 5 minutes
- User-friendly error messages for each validation type

Technical details:
- Rate limit: 10 msg/min sliding window
- Duplicate check: Last 5 messages within 60s window
- Profanity: bad-words + 11 Polish words
- Memory management: Periodic cleanup of expired data
This commit is contained in:
Radosław Gierwiało
2025-12-02 23:59:16 +01:00
parent 4a91a10aff
commit ace33111a4
4 changed files with 228 additions and 23 deletions

View File

@@ -0,0 +1,199 @@
/**
* Message Validation Middleware
* Spam detection, profanity filter, and duplicate message detection
*/
const Filter = require('bad-words');
const { MESSAGE_MAX_LENGTH } = require('../constants');
// Initialize profanity filter
const filter = new Filter();
// Add Polish profanity words (basic list - extend as needed)
const polishBadWords = [
'kurwa', 'kurde', 'chuj', 'pierdol', 'jebać', 'kurewski',
'cholera', 'skurwysyn', 'dupek', 'gówno', 'zasraniec'
];
filter.addWords(...polishBadWords);
// Track recent messages per user for duplicate detection
// userId -> array of { content, timestamp }
const recentMessages = new Map();
const DUPLICATE_CHECK_COUNT = 5; // Check last 5 messages
const DUPLICATE_TIME_WINDOW = 60000; // 1 minute
// Rate limiting per user
// userId -> array of timestamps
const messageTimestamps = new Map();
const RATE_LIMIT_WINDOW = 60000; // 1 minute
const RATE_LIMIT_MAX = 10; // 10 messages per minute
/**
* Validate message content
* @param {number} userId - User ID
* @param {string} content - Message content
* @returns {Object} { valid: boolean, error?: string }
*/
function validateMessage(userId, content) {
// 1. Basic validation
if (!content || typeof content !== 'string') {
return { valid: false, error: 'Invalid message content' };
}
const trimmedContent = content.trim();
if (trimmedContent.length === 0) {
return { valid: false, error: 'Message cannot be empty' };
}
if (content.length > MESSAGE_MAX_LENGTH) {
return { valid: false, error: `Message too long. Maximum ${MESSAGE_MAX_LENGTH} characters allowed.` };
}
// 2. Rate limiting check
const rateLimitResult = checkRateLimit(userId);
if (!rateLimitResult.valid) {
return rateLimitResult;
}
// 3. Duplicate detection
const duplicateResult = checkDuplicate(userId, trimmedContent);
if (!duplicateResult.valid) {
return duplicateResult;
}
// 4. Profanity filter
if (filter.isProfane(content)) {
return {
valid: false,
error: 'Message contains inappropriate language. Please keep the chat respectful.'
};
}
// All checks passed - record message for future duplicate detection
recordMessage(userId, trimmedContent);
return { valid: true };
}
/**
* Check rate limit for user
* @param {number} userId - User ID
* @returns {Object} { valid: boolean, error?: string }
*/
function checkRateLimit(userId) {
const now = Date.now();
// Get user's message timestamps
if (!messageTimestamps.has(userId)) {
messageTimestamps.set(userId, []);
}
const timestamps = messageTimestamps.get(userId);
// Remove old timestamps outside the window
const recentTimestamps = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
messageTimestamps.set(userId, recentTimestamps);
// Check if limit exceeded
if (recentTimestamps.length >= RATE_LIMIT_MAX) {
return {
valid: false,
error: 'You are sending messages too quickly. Please slow down.'
};
}
// Add current timestamp
recentTimestamps.push(now);
return { valid: true };
}
/**
* Check for duplicate messages
* @param {number} userId - User ID
* @param {string} content - Message content (trimmed)
* @returns {Object} { valid: boolean, error?: string }
*/
function checkDuplicate(userId, content) {
if (!recentMessages.has(userId)) {
return { valid: true };
}
const now = Date.now();
const userMessages = recentMessages.get(userId);
// Remove old messages outside time window
const recentUserMessages = userMessages.filter(msg => now - msg.timestamp < DUPLICATE_TIME_WINDOW);
// Check if this exact message was sent recently
const isDuplicate = recentUserMessages.some(msg => msg.content === content);
if (isDuplicate) {
return {
valid: false,
error: 'You already sent this message recently. Please avoid spamming.'
};
}
return { valid: true };
}
/**
* Record message for duplicate detection
* @param {number} userId - User ID
* @param {string} content - Message content (trimmed)
*/
function recordMessage(userId, content) {
if (!recentMessages.has(userId)) {
recentMessages.set(userId, []);
}
const userMessages = recentMessages.get(userId);
const now = Date.now();
// Add new message
userMessages.push({ content, timestamp: now });
// Keep only last N messages
if (userMessages.length > DUPLICATE_CHECK_COUNT) {
userMessages.shift();
}
// Clean up old messages
const recent = userMessages.filter(msg => now - msg.timestamp < DUPLICATE_TIME_WINDOW);
recentMessages.set(userId, recent);
}
/**
* Clean up old data periodically (memory management)
*/
function cleanup() {
const now = Date.now();
// Clean rate limit data
for (const [userId, timestamps] of messageTimestamps.entries()) {
const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
if (recent.length === 0) {
messageTimestamps.delete(userId);
} else {
messageTimestamps.set(userId, recent);
}
}
// Clean duplicate detection data
for (const [userId, messages] of recentMessages.entries()) {
const recent = messages.filter(msg => now - msg.timestamp < DUPLICATE_TIME_WINDOW);
if (recent.length === 0) {
recentMessages.delete(userId);
} else {
recentMessages.set(userId, recent);
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanup, 5 * 60 * 1000);
module.exports = {
validateMessage,
};

View File

@@ -3,6 +3,7 @@ const { verifyToken } = require('../utils/auth');
const { prisma } = require('../utils/db');
const { ACTIONS, log: activityLog } = require('../services/activityLog');
const { MESSAGE_MAX_LENGTH } = require('../constants');
const { validateMessage } = require('../middleware/messageValidation');
// Track active users in each event room
const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar }
@@ -311,17 +312,10 @@ function initializeSocket(httpServer) {
return socket.emit('error', { message: 'Not in an event room' });
}
// Validate message content
if (!content || typeof content !== 'string') {
return socket.emit('error', { message: 'Invalid message content' });
}
if (content.trim().length === 0) {
return socket.emit('error', { message: 'Message cannot be empty' });
}
if (content.length > MESSAGE_MAX_LENGTH) {
return socket.emit('error', { message: `Message too long. Maximum ${MESSAGE_MAX_LENGTH} characters allowed.` });
// Validate message (length, rate limit, duplicates, profanity)
const validation = validateMessage(socket.user.id, content);
if (!validation.valid) {
return socket.emit('error', { message: validation.error });
}
const eventId = socket.currentEventId;
@@ -448,17 +442,10 @@ function initializeSocket(httpServer) {
// Send message to match room
socket.on('send_match_message', async ({ matchId, content }) => {
try {
// Validate message content
if (!content || typeof content !== 'string') {
return socket.emit('error', { message: 'Invalid message content' });
}
if (content.trim().length === 0) {
return socket.emit('error', { message: 'Message cannot be empty' });
}
if (content.length > MESSAGE_MAX_LENGTH) {
return socket.emit('error', { message: `Message too long. Maximum ${MESSAGE_MAX_LENGTH} characters allowed.` });
// Validate message (length, rate limit, duplicates, profanity)
const validation = validateMessage(socket.user.id, content);
if (!validation.valid) {
return socket.emit('error', { message: validation.error });
}
const roomName = `match_${matchId}`;