feat(spam-moderation): implement spam detection and moderation for team invitations and signups

- Integrated SpamDetector utility to check for spam patterns in team names and user names during signup and invitation processes.
- Enhanced TeamMembersController to log and block obvious spam invitations while allowing suspicious ones for review.
- Updated passport-local-signup strategy to flag high-risk signups and log details for admin review.
- Added moderation routes to handle spam-related actions and integrated rate limiting for invitation requests.
- Improved frontend components to provide real-time spam warnings during organization name input, enhancing user feedback.
This commit is contained in:
Chamika J
2025-07-31 15:52:08 +05:30
parent 7635676289
commit e5e56e48f8
16 changed files with 1523 additions and 10 deletions

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
import { RootState } from '@/app/store';
import { sanitizeInput } from '@/utils/sanitizeInput';
import { SpamDetector } from '@/utils/spamDetector';
const { Title, Paragraph, Text } = Typography;
@@ -29,6 +30,7 @@ export const OrganizationStep: React.FC<Props> = ({
const dispatch = useDispatch();
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRef = useRef<InputRef>(null);
const [spamWarning, setSpamWarning] = useState<string>('');
// Autofill organization name if not already set
useEffect(() => {
@@ -44,7 +46,19 @@ export const OrganizationStep: React.FC<Props> = ({
};
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = sanitizeInput(e.target.value);
const rawValue = e.target.value;
const sanitizedValue = sanitizeInput(rawValue);
// Check for spam patterns
const spamCheck = SpamDetector.detectSpam(rawValue);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(rawValue)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
dispatch(setOrganizationName(sanitizedValue));
};
@@ -60,12 +74,25 @@ export const OrganizationStep: React.FC<Props> = ({
</Paragraph>
</div>
{/* Spam Warning */}
{spamWarning && (
<div className="mb-4">
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
/>
</div>
)}
{/* Main Form Card */}
<div className="mb-6">
<Card
className="border-2 hover:shadow-md transition-all duration-200"
style={{
borderColor: token?.colorPrimary,
borderColor: spamWarning ? token?.colorWarning : token?.colorPrimary,
backgroundColor: token?.colorBgContainer
}}
>

View File

@@ -1,10 +1,11 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography, Alert } from '@/shared/antd-imports';
import TextArea from 'antd/es/input/TextArea';
import { TFunction } from 'i18next';
import { useState, useEffect } from 'react';
import { SpamDetector } from '@/utils/spamDetector';
interface OrganizationNameProps {
themeMode: string;
@@ -16,6 +17,7 @@ interface OrganizationNameProps {
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
const [isEditable, setIsEditable] = useState(false);
const [newName, setNewName] = useState(name);
const [spamWarning, setSpamWarning] = useState<string>('');
useEffect(() => {
setNewName(name);
@@ -34,7 +36,18 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewName(e.target.value);
const value = e.target.value;
setNewName(value);
// Check for spam patterns
const spamCheck = SpamDetector.detectSpam(value);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(value)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
};
const updateOrganizationName = async () => {
@@ -62,6 +75,16 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('name')}
</Typography.Title>
{spamWarning && (
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
style={{ marginBottom: '8px' }}
/>
)}
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
{isEditable ? (

View File

@@ -1,9 +1,10 @@
import { Divider, Form, Input, message, Modal, Typography } from '@/shared/antd-imports';
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
import { ITeamGetResponse } from '@/types/teams/team.type';
import { SpamDetector } from '@/utils/spamDetector';
interface EditTeamNameModalProps {
team: ITeamGetResponse | null;
@@ -16,6 +17,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [updating, setUpdating] = useState(false);
const [spamWarning, setSpamWarning] = useState<string>('');
useEffect(() => {
if (team) {
@@ -67,6 +69,16 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
destroyOnClose={true}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
{spamWarning && (
<Alert
message={spamWarning}
type="warning"
showIcon
closable
onClose={() => setSpamWarning('')}
style={{ marginBottom: '16px' }}
/>
)}
<Form.Item
name="name"
label={t('name')}
@@ -77,7 +89,20 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
},
]}
>
<Input placeholder={t('namePlaceholder')} />
<Input
placeholder={t('namePlaceholder')}
onChange={(e) => {
const value = e.target.value;
const spamCheck = SpamDetector.detectSpam(value);
if (spamCheck.isSpam) {
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
} else if (SpamDetector.isHighRiskContent(value)) {
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
} else {
setSpamWarning('');
}
}}
/>
</Form.Item>
</Form>
</Modal>

View File

@@ -0,0 +1,141 @@
export interface SpamDetectionResult {
isSpam: boolean;
score: number;
reasons: string[];
}
export class SpamDetector {
private static readonly SPAM_PATTERNS = [
// URLs and links
/https?:\/\//i,
/www\./i,
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf)\b/i,
// Common spam phrases
/click\s*(here|link|now)/i,
/urgent|emergency|immediate/i,
/win|won|winner|prize|reward/i,
/free|bonus|gift|offer/i,
/check\s*(out|this|pay)/i,
/blockchain|crypto|bitcoin|compensation/i,
/cash|money|dollars?|\$\d+/i,
// Excessive special characters
/[!]{3,}/,
/[🔔⬅👆💰$]{2,}/,
/\b[A-Z]{5,}\b/,
// Suspicious formatting
/\s{3,}/,
/[.]{3,}/
];
private static readonly SUSPICIOUS_WORDS = [
'urgent', 'emergency', 'click', 'link', 'win', 'winner', 'prize',
'free', 'bonus', 'cash', 'money', 'blockchain', 'crypto', 'compensation',
'check', 'pay', 'reward', 'offer', 'gift'
];
public static detectSpam(text: string): SpamDetectionResult {
if (!text || typeof text !== 'string') {
return { isSpam: false, score: 0, reasons: [] };
}
const normalizedText = text.toLowerCase().trim();
const reasons: string[] = [];
let score = 0;
// Check for URL patterns
for (const pattern of this.SPAM_PATTERNS) {
if (pattern.test(text)) {
score += 30;
if (pattern.toString().includes('https?') || pattern.toString().includes('www')) {
reasons.push('Contains suspicious URLs or links');
} else if (pattern.toString().includes('urgent|emergency')) {
reasons.push('Contains urgent/emergency language');
} else if (pattern.toString().includes('win|won|winner')) {
reasons.push('Contains prize/winning language');
} else if (pattern.toString().includes('cash|money')) {
reasons.push('Contains monetary references');
} else if (pattern.toString().includes('blockchain|crypto')) {
reasons.push('Contains cryptocurrency references');
} else if (pattern.toString().includes('[!]{3,}')) {
reasons.push('Excessive use of exclamation marks');
} else if (pattern.toString().includes('[🔔⬅👆💰$]')) {
reasons.push('Contains suspicious emojis or symbols');
} else if (pattern.toString().includes('[A-Z]{5,}')) {
reasons.push('Contains excessive capital letters');
}
}
}
// Check for excessive suspicious words
const suspiciousWordCount = this.SUSPICIOUS_WORDS.filter(word =>
normalizedText.includes(word)
).length;
if (suspiciousWordCount >= 2) {
score += suspiciousWordCount * 15;
reasons.push(`Contains ${suspiciousWordCount} suspicious words`);
}
// Check text length - very short or very long names are suspicious
if (text.length < 2) {
score += 20;
reasons.push('Text too short');
} else if (text.length > 100) {
score += 25;
reasons.push('Text unusually long');
}
// Check for repeated characters
if (/(.)\1{4,}/.test(text)) {
score += 20;
reasons.push('Contains repeated characters');
}
// Check for mixed scripts (potential homograph attack)
const hasLatin = /[a-zA-Z]/.test(text);
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
const hasGreek = /[\u0370-\u03FF]/.test(text);
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
score += 40;
reasons.push('Contains mixed character scripts');
}
const isSpam = score >= 50;
return {
isSpam,
score,
reasons: [...new Set(reasons)] // Remove duplicates
};
}
public static isHighRiskContent(text: string): boolean {
const patterns = [
/gclnk\.com/i,
/bit\.ly/i,
/tinyurl/i,
/\$\d{3,}/,
/blockchain.*compensation/i,
/urgent.*check/i
];
return patterns.some(pattern => pattern.test(text));
}
public static sanitizeText(text: string): string {
if (!text || typeof text !== 'string') return '';
return text
.trim()
.replace(/https?:\/\/[^\s]+/gi, '[URL_REMOVED]')
.replace(/www\.[^\s]+/gi, '[URL_REMOVED]')
.replace(/[🔔⬅👆💰$]{2,}/g, '')
.replace(/[!]{3,}/g, '!')
.replace(/\s{3,}/g, ' ')
.substring(0, 100);
}
}