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:
1
worklenz-frontend/.gitignore
vendored
1
worklenz-frontend/.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/public/tinymce
|
||||
/docs
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
141
worklenz-frontend/src/utils/spamDetector.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user