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:
16
backend/package-lock.json
generated
16
backend/package-lock.json
generated
@@ -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,
|
||||
|
||||
@@ -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": ["<rootDir>/jest.setup.js"],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/jest.setup.js"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"/node_modules/"
|
||||
],
|
||||
|
||||
199
backend/src/middleware/messageValidation.js
Normal file
199
backend/src/middleware/messageValidation.js
Normal 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,
|
||||
};
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user