From 66f6d17a2a400a4e80bd062a5a83014b2b529d85 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 15:47:28 +0300 Subject: [PATCH] Add profanity in display and usernames, fix Docker setup --- backend/routes/account.py | 11 ++ backend/routes/messaging.py | 87 ++++++++++++---- backend/routes/profile.py | 11 ++ backend/security/audit.py | 20 +++- backend/security/profanity.py | 190 +++++++++++++++++++++++++++++++++- deployment/.dockerignore | 3 +- 6 files changed, 296 insertions(+), 26 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index cd49c63..7784c0f 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -16,6 +16,7 @@ from validation import is_valid_password, is_valid_username, is_valid_display_na import os from security.audit import log_security +from security.profanity import contains_profanity router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -185,12 +186,22 @@ def register(request: RegisterRequest, http: Request, db: Session = Depends(get_ status_code=status.HTTP_400_BAD_REQUEST, detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" ) + if contains_profanity(username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Имя пользователя содержит запрещённые слова" + ) if not is_valid_display_name(display_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" ) + if contains_profanity(display_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Отображаемое имя содержит запрещённые слова" + ) if not is_valid_password(password): raise HTTPException( diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7f4aea3..1f2d64d 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -7,6 +7,7 @@ import re import uuid import asyncio import time +import unicodedata from collections import defaultdict, deque from difflib import SequenceMatcher from types import SimpleNamespace @@ -44,22 +45,51 @@ _SPAM_SIMILARITY_THRESHOLD = 0.88 _SPAM_MESSAGE_LIMIT = 5 _BURST_WINDOW_SECONDS = 30 _BURST_COUNT_THRESHOLD = 20 +_SHORT_MESSAGE_LENGTH = 8 +_SHORT_MESSAGE_REPEAT_LIMIT = 4 -_recent_message_cache: dict[int, deque[tuple[str, float]]] = defaultdict(deque) +_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque) _message_rate_cache: dict[int, deque[float]] = defaultdict(deque) _burst_last_logged: dict[int, float] = {} +def _normalize_for_spam(text: str) -> str: + normalized = unicodedata.normalize("NFKC", text or "").casefold() + # Remove whitespace and punctuation while keeping alphanumerics + cleaned = re.sub(r"[^0-9a-zа-яё]+", "", normalized, flags=re.IGNORECASE) + return cleaned + + def _monitor_public_message_activity(user: User, content: str, db: Session) -> None: now = time.time() + def suspend(reason: str, event: str, **extra: Any) -> None: + if user.suspended or user.id == 1: + return + user.suspended = True + user.suspension_reason = reason + db.commit() + log_security( + event, + severity="warning", + user_id=user.id, + username=user.username, + reason=reason, + **extra, + ) + try: + asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) + except Exception: + pass + # Rate tracking for burst detection rate_bucket = _message_rate_cache[user.id] rate_bucket.append(now) while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: rate_bucket.popleft() - if len(rate_bucket) >= _BURST_COUNT_THRESHOLD: + burst_count = len(rate_bucket) + if burst_count >= _BURST_COUNT_THRESHOLD: last_logged = _burst_last_logged.get(user.id) if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS: log_security( @@ -67,39 +97,52 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N severity="warning", user_id=user.id, username=user.username, - count=len(rate_bucket), + count=burst_count, window_seconds=_BURST_WINDOW_SECONDS, ) _burst_last_logged[user.id] = now + suspend( + "Automatic suspension: excessive message rate", + "auto_suspension_public_burst", + count=burst_count, + window_seconds=_BURST_WINDOW_SECONDS, + ) # Similarity-based spam detection + normalized = _normalize_for_spam(content) history = _recent_message_cache[user.id] - history.append((content, now)) - while history and now - history[0][1] > _SPAM_WINDOW_SECONDS: + while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: history.popleft() - similar_messages = sum( - 1 for previous_content, _ in history - if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD + prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized) + prior_similar = sum( + 1 + for prev_norm, _, _ in history + if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD ) - if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1: - reason = "Automatic suspension: repeated similar public messages" - user.suspended = True - user.suspension_reason = reason - db.commit() - log_security( + history.append((normalized, content, now)) + + total_matches = prior_same + prior_similar + 1 + + if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: + suspend( + "Automatic suspension: repeated short messages", "auto_suspension_public_spam", - severity="warning", - user_id=user.id, - username=user.username, - similar_messages=similar_messages, + occurrences=prior_same + 1, window_seconds=_SPAM_WINDOW_SECONDS, + match_type="short", + ) + return + + if total_matches >= _SPAM_MESSAGE_LIMIT: + suspend( + "Automatic suspension: repeated similar public messages", + "auto_suspension_public_spam", + similar_messages=total_matches, + window_seconds=_SPAM_WINDOW_SECONDS, + match_type="similar", ) - try: - asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) - except Exception: - pass def convert_message(msg: Message) -> dict: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 388de5c..bcf5716 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -15,6 +15,7 @@ from validation import is_valid_username, is_valid_display_name from similarity import is_user_similar_to_verified from .messaging import messagingManager from security.audit import log_security +from security.profanity import contains_profanity router = APIRouter() @@ -180,6 +181,11 @@ async def update_user_profile( status_code=400, detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" ) + if contains_profanity(username): + raise HTTPException( + status_code=400, + detail="Имя пользователя содержит запрещённые слова" + ) # Check if username is already taken by another user existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first() @@ -197,6 +203,11 @@ async def update_user_profile( status_code=400, detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" ) + if contains_profanity(display_name): + raise HTTPException( + status_code=400, + detail="Отображаемое имя содержит запрещённые слова" + ) current_user.display_name = display_name updated = True diff --git a/backend/security/audit.py b/backend/security/audit.py index ded1215..acf8766 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -145,10 +145,28 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: if action == "auto_suspension_public_spam": lines = [ f"Automatic suspension triggered for {_format_user(fields)}", - f"Similar messages detected: {fields.get('similar_messages')}", ] + match_type = fields.get("match_type") + if match_type: + lines.append(f"Match type: {match_type}") + similar = fields.get("similar_messages") + occurrences = fields.get("occurrences") + if similar: + lines.append(f"Similar messages detected: {similar}") + if occurrences and not similar: + lines.append(f"Occurrences: {occurrences}") if fields.get("window_seconds"): lines.append(f"Observation window: {fields['window_seconds']} seconds") + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") + return lines + if action == "auto_suspension_public_burst": + lines = [ + f"Automatic suspension triggered for {_format_user(fields)}", + f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", + ] + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") return lines if action == "public_message_burst": return [ diff --git a/backend/security/profanity.py b/backend/security/profanity.py index b1f3d62..8c49d30 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -15,7 +15,9 @@ _CUSTOM_RU_TERMS: Set[str] = { "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", - "долбоёб", "долбоеб", "дебил", "член", "проститутка", "урод", + "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", + "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", + "пидоры", "пидорас", "пидорасы", "пидорасов", } _ADULT_TERMS: Set[str] = { @@ -33,8 +35,167 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE), re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), ) +_LEET_MAP = { + "0": "о", + "o": "о", + "о": "о", + "a": "а", + "@": "а", + "4": "а", + "а": "а", + "e": "е", + "ё": "е", + "3": "е", + "c": "с", + "s": "с", + "с": "с", + "x": "х", + "х": "х", + "t": "т", + "т": "т", + "p": "п", + "п": "п", + "n": "н", + "н": "н", + "m": "м", + "м": "м", + "y": "у", + "u": "у", + "у": "у", + "g": "г", + "г": "г", + "v": "в", + "в": "в", + "f": "ф", + "ф": "ф", + "i": "и", + "1": "и", + "и": "и", +} + +_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( + ("generic", ("айфон", "топ")), + ("generic", ("самсунг", "говно")), +) + +_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json") +_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} + + +def _normalize_char(ch: str) -> str: + lower = ch.lower() + return _LEET_MAP.get(lower, lower) + + +def _normalize_token(token: str) -> str: + return "".join(_normalize_char(ch) for ch in token) + + +def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: + tokens: List[Tuple[int, int, str]] = [] + start: int | None = None + buffer: List[str] = [] + + for idx, ch in enumerate(text): + if ch.isalnum() or ch in {"@", "#", "_"}: + if start is None: + start = idx + buffer.append(ch) + else: + if buffer and start is not None: + token_raw = "".join(buffer) + tokens.append((start, idx, _normalize_token(token_raw))) + buffer.clear() + start = None + if buffer and start is not None: + token_raw = "".join(buffer) + tokens.append((start, len(text), _normalize_token(token_raw))) + return tokens + + +def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool: + if a == b: + return True + if max_distance <= 0: + return False + if abs(len(a) - len(b)) > max_distance: + return False + + previous = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + current = [i] + best = current[0] + for j, cb in enumerate(b, 1): + insert_cost = current[j - 1] + 1 + delete_cost = previous[j] + 1 + replace_cost = previous[j - 1] + (0 if ca == cb else 1) + cost = min(insert_cost, delete_cost, replace_cost) + current.append(cost) + if cost < best: + best = cost + if best > max_distance: + return False + previous = current + return previous[-1] <= max_distance + + +def _load_sensitive_phrases() -> List[Tuple[str, ...]]: + if not _SENSITIVE_PHRASE_PATH.exists(): + return [] + try: + payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8")) + phrases: List[Tuple[str, ...]] = [] + if isinstance(payload, list): + for entry in payload: + if isinstance(entry, list) and entry: + normalized = tuple(str(part).strip() for part in entry if str(part).strip()) + if normalized: + phrases.append(normalized) + return phrases + except Exception: + return [] + + +def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]: + if group not in _PHRASE_CACHE: + base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group] + if group == "sensitive": + base.extend(_load_sensitive_phrases()) + _PHRASE_CACHE[group] = tuple( + tuple(_normalize_token(part) for part in phrase) + for phrase in base + ) + return _PHRASE_CACHE[group] + + +def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]: + tokens = _tokenize_with_spans(text) + if not tokens: + return [] + + spans: List[Tuple[int, int]] = [] + normalized_phrases = _get_phrases(group) + + for index in range(len(tokens)): + for phrase in normalized_phrases: + if index + len(phrase) > len(tokens): + continue + matches = True + for offset, target in enumerate(phrase): + token = tokens[index + offset][2] + if not _edit_distance_limited(token, target): + matches = False + break + if matches: + span_start = tokens[index][0] + span_end = tokens[index + len(phrase) - 1][1] + spans.append((span_start, span_end)) + return spans + _dictionary_lock = RLock() _blocklist_signature: Tuple[str, ...] | None = None _profanity = Profanity() @@ -96,7 +257,11 @@ def _apply_phrase_filters(text: str) -> str: match = pattern.search(result) if not match: break - result = result[:match.start()] + ("\\*" * (match.end() - match.start())) + result[match.end():] + result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():] + + for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True): + result = result[:start] + ("*" * (end - start)) + result[end:] + return result @@ -109,6 +274,27 @@ def censor_text(text: str) -> str: return _profanity.censor(preprocessed, censor_char="\\*") +def contains_profanity(text: str) -> bool: + if not text: + return False + + _rebuild_dictionary() + for pattern in _PHRASE_PATTERNS: + if pattern.search(text): + return True + if _find_fuzzy_phrase_spans(text, "generic"): + return True + return _profanity.contains_profanity(text) + + +def contains_sensitive_phrase(text: str) -> bool: + if not text: + return False + if _find_fuzzy_phrase_spans(text, "sensitive"): + return True + return False + + def get_blocklist() -> List[str]: with _dictionary_lock: return sorted(_load_blocklist()) diff --git a/deployment/.dockerignore b/deployment/.dockerignore index af433d6..d6dd40d 100644 --- a/deployment/.dockerignore +++ b/deployment/.dockerignore @@ -30,4 +30,5 @@ coverage test_results/ out -data \ No newline at end of file +data +logs \ No newline at end of file