Add profanity in display and usernames, fix Docker setup

This commit is contained in:
2025-11-09 15:47:28 +03:00
Unverified
parent 89daf2718b
commit 66f6d17a2a
6 changed files with 296 additions and 26 deletions
+11
View File
@@ -16,6 +16,7 @@ from validation import is_valid_password, is_valid_username, is_valid_display_na
import os import os
from security.audit import log_security from security.audit import log_security
from security.profanity import contains_profanity
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300 _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, status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" 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): if not is_valid_display_name(display_name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
) )
if contains_profanity(display_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Отображаемое имя содержит запрещённые слова"
)
if not is_valid_password(password): if not is_valid_password(password):
raise HTTPException( raise HTTPException(
+65 -22
View File
@@ -7,6 +7,7 @@ import re
import uuid import uuid
import asyncio import asyncio
import time import time
import unicodedata
from collections import defaultdict, deque from collections import defaultdict, deque
from difflib import SequenceMatcher from difflib import SequenceMatcher
from types import SimpleNamespace from types import SimpleNamespace
@@ -44,22 +45,51 @@ _SPAM_SIMILARITY_THRESHOLD = 0.88
_SPAM_MESSAGE_LIMIT = 5 _SPAM_MESSAGE_LIMIT = 5
_BURST_WINDOW_SECONDS = 30 _BURST_WINDOW_SECONDS = 30
_BURST_COUNT_THRESHOLD = 20 _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) _message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
_burst_last_logged: dict[int, float] = {} _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: def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
now = time.time() 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 tracking for burst detection
rate_bucket = _message_rate_cache[user.id] rate_bucket = _message_rate_cache[user.id]
rate_bucket.append(now) rate_bucket.append(now)
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft() 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) last_logged = _burst_last_logged.get(user.id)
if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS: if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS:
log_security( log_security(
@@ -67,39 +97,52 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
severity="warning", severity="warning",
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
count=len(rate_bucket), count=burst_count,
window_seconds=_BURST_WINDOW_SECONDS, window_seconds=_BURST_WINDOW_SECONDS,
) )
_burst_last_logged[user.id] = now _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 # Similarity-based spam detection
normalized = _normalize_for_spam(content)
history = _recent_message_cache[user.id] history = _recent_message_cache[user.id]
history.append((content, now)) while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
while history and now - history[0][1] > _SPAM_WINDOW_SECONDS:
history.popleft() history.popleft()
similar_messages = sum( prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
1 for previous_content, _ in history prior_similar = sum(
if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD 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: history.append((normalized, content, now))
reason = "Automatic suspension: repeated similar public messages"
user.suspended = True total_matches = prior_same + prior_similar + 1
user.suspension_reason = reason
db.commit() if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
log_security( suspend(
"Automatic suspension: repeated short messages",
"auto_suspension_public_spam", "auto_suspension_public_spam",
severity="warning", occurrences=prior_same + 1,
user_id=user.id,
username=user.username,
similar_messages=similar_messages,
window_seconds=_SPAM_WINDOW_SECONDS, 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: def convert_message(msg: Message) -> dict:
+11
View File
@@ -15,6 +15,7 @@ from validation import is_valid_username, is_valid_display_name
from similarity import is_user_similar_to_verified from similarity import is_user_similar_to_verified
from .messaging import messagingManager from .messaging import messagingManager
from security.audit import log_security from security.audit import log_security
from security.profanity import contains_profanity
router = APIRouter() router = APIRouter()
@@ -180,6 +181,11 @@ async def update_user_profile(
status_code=400, status_code=400,
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
) )
if contains_profanity(username):
raise HTTPException(
status_code=400,
detail="Имя пользователя содержит запрещённые слова"
)
# Check if username is already taken by another user # Check if username is already taken by another user
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first() 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, status_code=400,
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
) )
if contains_profanity(display_name):
raise HTTPException(
status_code=400,
detail="Отображаемое имя содержит запрещённые слова"
)
current_user.display_name = display_name current_user.display_name = display_name
updated = True updated = True
+19 -1
View File
@@ -145,10 +145,28 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "auto_suspension_public_spam": if action == "auto_suspension_public_spam":
lines = [ lines = [
f"Automatic suspension triggered for {_format_user(fields)}", 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"): if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} 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 return lines
if action == "public_message_burst": if action == "public_message_burst":
return [ return [
+188 -2
View File
@@ -15,7 +15,9 @@ _CUSTOM_RU_TERMS: Set[str] = {
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "урод", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
} }
_ADULT_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"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\b18\+\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"\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() _dictionary_lock = RLock()
_blocklist_signature: Tuple[str, ...] | None = None _blocklist_signature: Tuple[str, ...] | None = None
_profanity = Profanity() _profanity = Profanity()
@@ -96,7 +257,11 @@ def _apply_phrase_filters(text: str) -> str:
match = pattern.search(result) match = pattern.search(result)
if not match: if not match:
break 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 return result
@@ -109,6 +274,27 @@ def censor_text(text: str) -> str:
return _profanity.censor(preprocessed, censor_char="\\*") 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]: def get_blocklist() -> List[str]:
with _dictionary_lock: with _dictionary_lock:
return sorted(_load_blocklist()) return sorted(_load_blocklist())
+2 -1
View File
@@ -30,4 +30,5 @@ coverage
test_results/ test_results/
out out
data data
logs