diff --git a/backend/package-lock.json b/backend/package-lock.json index e384e2d..5aec113 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", + "bad-words": "^2.0.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -2863,6 +2864,21 @@ "@babel/core": "^7.0.0" } }, + "node_modules/bad-words": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bad-words/-/bad-words-2.0.0.tgz", + "integrity": "sha512-NsfaHcGgNsPTlWl54HrXawEfsJ5TLcIVa5KQcMoGzQTycCECcYfcGRWwjuxdYz7PbgarFl9Epv/+qT5JV+oBtA==", + "license": "MIT", + "dependencies": { + "badwords-list": "^1.0.0" + } + }, + "node_modules/badwords-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz", + "integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, diff --git a/backend/package.json b/backend/package.json index 31a7baa..4d56b34 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", + "bad-words": "^2.0.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -49,7 +50,9 @@ }, "jest": { "testEnvironment": "node", - "setupFilesAfterEnv": ["/jest.setup.js"], + "setupFilesAfterEnv": [ + "/jest.setup.js" + ], "coveragePathIgnorePatterns": [ "/node_modules/" ], diff --git a/backend/src/middleware/messageValidation.js b/backend/src/middleware/messageValidation.js new file mode 100644 index 0000000..cc3e966 --- /dev/null +++ b/backend/src/middleware/messageValidation.js @@ -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, +}; diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index c8d9e3b..e97e0b6 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -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}`;