diff --git a/SECURITY_FOLLOWUPS.md b/SECURITY_FOLLOWUPS.md new file mode 100644 index 0000000..659f5d1 --- /dev/null +++ b/SECURITY_FOLLOWUPS.md @@ -0,0 +1,9 @@ +# Security Follow-ups + +- Integrate log shipping/alerting (e.g. Loki or ELK) so events from `backend/logs/*.log` raise actionable notifications instead of remaining on disk. +- Add automated review of `security.log` for repeated `auth_bruteforce_detected` and burst messaging entries; trigger temporary IP bans or captcha challenges when thresholds are exceeded. +- Extend profanity filtering tests to cover dynamic blocklist updates and multi-language phrases; add regression suite to ensure adult-content words remain blocked. +- Implement DM spam heuristics similar to public chat (rate limiting, reaction abuse detection) and log attempts that target users who blocked the sender. +- Harden WebSocket session handling by recycling DB sessions per request or adopting async session factories to keep long-lived connections from retaining database handles indefinitely. +- Wire the new moderator blocklist endpoints into an authenticated UI workflow so operators can manage entries without shell access, and audit every change with responsible operator metadata. + diff --git a/backend/admin_cli.py b/backend/admin_cli.py new file mode 100644 index 0000000..cfd573f --- /dev/null +++ b/backend/admin_cli.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import os +import shlex +import sys +from getpass import getpass +from typing import Iterable, List, Optional, Tuple +import readline +import httpx +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + + +class CLIError(Exception): + """Generic CLI error with a human-readable message.""" + + +def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes: + return hmac.new(salt, ikm, hashlib.sha256).digest() + + +def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: + blocks: list[bytes] = [] + previous = b"" + counter = 1 + while len(b"".join(blocks)) < length: + previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest() + blocks.append(previous) + counter += 1 + return b"".join(blocks)[:length] + + +def derive_auth_secret(username: str, password: str) -> str: + salt = f"fromchat.user:{username}".encode("utf-8") + prk = _hkdf_extract(salt, password.encode("utf-8")) + okm = _hkdf_expand(prk, b"auth-secret", 32) + return base64.b64encode(okm).decode("utf-8") + + +def _read_single_key() -> str: + try: # Windows + import msvcrt # type: ignore + + ch = msvcrt.getch() + return ch.decode("utf-8", errors="ignore").lower() + except ImportError: + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch.lower() + + +class AdminCLI: + def __init__(self, api_url: str) -> None: + self.console = Console() + self.api_url = api_url.rstrip("/") + self.client = httpx.Client(base_url=self.api_url, timeout=30.0) + self.username: Optional[str] = None + self.token: Optional[str] = None + + # --------------------------- HTTP helpers --------------------------- # + def _auth_headers(self) -> dict: + headers: dict = {} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response: + rel_path = path.lstrip("/") + headers = kwargs.pop("headers", {}) + if auth: + headers.update(self._auth_headers()) + response = self.client.request(method, rel_path, headers=headers, **kwargs) + if response.status_code >= 400: + detail = "" + try: + payload = response.json() + if isinstance(payload, dict): + detail = payload.get("detail") or payload.get("message") or "" + except Exception: + detail = response.text + message = f"{response.status_code} {response.reason_phrase}" + if detail: + message = f"{message}: {detail}" + raise CLIError(message.strip()) + return response + + # --------------------------- CLI primitives ------------------------- # + def _require_auth(self) -> None: + if not self.token: + raise CLIError("You must login before running this command.") + + def _resolve_user(self, identifier: str) -> dict: + self._require_auth() + if identifier.isdigit(): + response = self._request("GET", f"user/id/{identifier}") + else: + response = self._request("GET", f"user/{identifier.replace('@', '')}") + return response.json() + + def _confirm(self, prompt: str) -> bool: + self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="") + choice = _read_single_key() + self.console.print("") # move to next line + return choice == "y" + + def _render_user(self, user: dict) -> None: + table = Table(show_header=False) + table.add_row("ID", str(user.get("id"))) + table.add_row("Username", user.get("username", "")) + table.add_row("Display name", user.get("display_name", "")) + table.add_row("Verified", "✅" if user.get("verified") else "❌") + if user.get("suspended"): + table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})") + else: + table.add_row("Suspended", "✅ Active") + self.console.print(table) + + # --------------------------- Commands ------------------------------- # + def cmd_login(self, args: List[str]) -> None: + if args: + username = args[0] + else: + username = self.console.input("[bold cyan]Username[/]: ").strip() + if not username: + raise CLIError("Username is required.") + + password = getpass("Password: ") + derived_password = derive_auth_secret(username, password) + payload = {"username": username, "password": derived_password} + response = self._request("POST", "login", json=payload, auth=False) + body = response.json() + token = body.get("token") + if not token: + raise CLIError("Authentication succeeded but token was not returned.") + self.token = token + self.username = username + self.console.print("[bold green]Login successful.[/]") + + def cmd_suspend(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: suspend ") + identifier = args[0] + user = self._resolve_user(identifier) + self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red")) + self._render_user(user) + reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip() + if not self._confirm(f"Confirm suspension of {user.get('username')}?"): + self.console.print("[yellow]Suspension cancelled.[/]") + return + payload = {"reason": reason} + self._request("POST", f"user/{user['id']}/suspend", json=payload) + log_reason = reason or "no reason provided" + self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]") + + def cmd_unsuspend(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unsuspend ") + identifier = args[0] + user = self._resolve_user(identifier) + self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green")) + self._render_user(user) + if not self._confirm(f"Unsuspend {user.get('username')}?"): + self.console.print("[yellow]Unsuspension cancelled.[/]") + return + self._request("POST", f"user/{user['id']}/unsuspend") + self.console.print(f"[bold green]User {user['username']} unsuspended.[/]") + + def cmd_block_word(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: block-word [additional words...]") + self._require_auth() + words = args + response = self._request("POST", "moderation/blocklist", json={"words": words}) + data = response.json() + added = data.get("added", []) + current = data.get("words", []) + if added: + self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]") + else: + self.console.print("[yellow]No new words added.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_list_users(self) -> None: + self._require_auth() + payload = self._request("GET", "user/list").json() + users = payload.get("users", []) + table = Table(title="Users", show_lines=False) + table.add_column("ID") + table.add_column("Username") + table.add_column("Display name") + table.add_column("Suspended") + for user in users: + table.add_row( + str(user.get("id")), + user.get("username", ""), + user.get("display_name", ""), + "🚫" if user.get("suspended") else "✅", + ) + self.console.print(table) + + def cmd_user(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: user ") + user = self._resolve_user(args[0]) + self._render_user(user) + + def cmd_delete(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: delete ") + user = self._resolve_user(args[0]) + self.console.print(Panel.fit("[bold red]Delete user[/]", style="red")) + self._render_user(user) + if not self._confirm(f"Permanently delete {user.get('username')}?"): + self.console.print("[yellow]Deletion cancelled.[/]") + return + self._request("POST", f"user/{user['id']}/delete") + self.console.print(f"[bold red]User {user['username']} deleted.[/]") + + def cmd_unblock_word(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unblock-word [additional words...]") + self._require_auth() + response = self._request("DELETE", "moderation/blocklist", json={"words": args}) + data = response.json() + removed = data.get("removed", []) + current = data.get("words", []) + if removed: + self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]") + else: + self.console.print("[yellow]No matching words removed.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_verify(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: verify ") + user = self._resolve_user(args[0]) + if user.get("verified"): + self.console.print(f"[yellow]{user['username']} is already verified.[/]") + return + self._request("POST", f"user/{user['id']}/verify") + self.console.print(f"[bold green]{user['username']} marked as verified.[/]") + + def cmd_unverify(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unverify ") + user = self._resolve_user(args[0]) + if not user.get("verified"): + self.console.print(f"[yellow]{user['username']} is already unverified.[/]") + return + self._request("POST", f"user/{user['id']}/verify") + self.console.print(f"[bold green]{user['username']} is now unverified.[/]") + + def cmd_list_blocklist(self) -> None: + self._require_auth() + response = self._request("GET", "moderation/blocklist") + words = response.json().get("words", []) + if not words: + self.console.print("[cyan]Blocklist is empty.[/]") + return + table = Table(title="Blocked Words", show_lines=True) + table.add_column("Word / Phrase") + for entry in words: + table.add_row(entry) + self.console.print(table) + + def cmd_help(self) -> None: + cmds = { + "login [username]": "Authenticate as owner/admin.", + "suspend ": "Suspend account (alias: ban).", + "unsuspend ": "Unsuspend account (alias: unban).", + "delete ": "Permanently delete the user account.", + "verify ": "Mark user as verified.", + "unverify ": "Remove verification flag.", + "block-word ": "Add words/phrases to chat filter.", + "unblock-word ": "Remove words/phrases from filter.", + "blocklist": "Show current blocklist.", + "list": "List all users.", + "user ": "Show detailed user information.", + "whoami": "Display current session context.", + "help": "Show this help panel.", + "exit": "Quit the CLI.", + } + table = Table(title="Available Commands") + table.add_column("Command", style="cyan") + table.add_column("Description", style="white") + for cmd, desc in cmds.items(): + table.add_row(cmd, desc) + self.console.print(table) + + def cmd_whoami(self) -> None: + if not self.token: + self.console.print("[yellow]Not authenticated.[/]") + return + self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})") + + # --------------------------- Main loop ------------------------------ # + def run(self) -> None: + self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta")) + while True: + prompt_identity = self.username or "guest" + try: + prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m " + raw = input(prompt_str).strip() + except (KeyboardInterrupt, EOFError): + self.console.print("\n[red]Exiting...[/]") + break + + if not raw: + continue + + try: + parts = shlex.split(raw) + except ValueError as exc: + self.console.print(f"[red]Parse error:[/] {exc}") + continue + + command = parts[0].lstrip("/").lower() + args = parts[1:] + + if command in {"exit", "quit"}: + self.console.print("[red]Goodbye.[/]") + break + + try: + if command == "login": + self.cmd_login(args) + elif command in {"suspend", "ban"}: + self.cmd_suspend(args) + elif command in {"unsuspend", "unban"}: + self.cmd_unsuspend(args) + elif command == "block-word": + self.cmd_block_word(args) + elif command == "unblock-word": + self.cmd_unblock_word(args) + elif command == "blocklist": + self.cmd_list_blocklist() + elif command == "verify": + self.cmd_verify(args) + elif command == "unverify": + self.cmd_unverify(args) + elif command in {"delete", "remove"}: + self.cmd_delete(args) + elif command == "list": + self.cmd_list_users() + elif command == "user": + self.cmd_user(args) + elif command == "help": + self.cmd_help() + elif command == "whoami": + self.cmd_whoami() + else: + self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]") + except CLIError as err: + self.console.print(f"[red]Error:[/] {err}") + except httpx.RequestError as err: + self.console.print(f"[red]Network error:[/] {err}") + + self.client.close() + + +def main(argv: Optional[Iterable[str]] = None) -> None: + parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI") + parser.add_argument( + "--api-url", + default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8301/api"), + help="Base API URL for the FromChat backend (default: %(default)s).", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + cli = AdminCLI(args.api_url) + cli.run() + + +if __name__ == "__main__": + main() + diff --git a/backend/app.py b/backend/app.py index 23c30c2..b4ed2ad 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,16 +1,18 @@ -from fastapi import FastAPI +import time +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import subprocess import sys import os -from constants import DATABASE_URL -from routes import account, messaging, profile, push, webrtc, devices +from routes import account, messaging, profile, push, webrtc, devices, moderation import logging from models import User from constants import OWNER_USERNAME -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker + +from db import POOL_CONFIG, SessionLocal +from logging_config import access_logger # noqa: F401 - ensure loggers configured +from security.audit import log_access logger = logging.getLogger("uvicorn.error") @@ -33,11 +35,7 @@ async def lifespan(app: FastAPI): raise try: - engine = create_engine(DATABASE_URL) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - with SessionLocal() as db: - # Find the owner user owner = db.query(User).filter(User.username == OWNER_USERNAME).first() if owner and not owner.verified: owner.verified = True @@ -47,10 +45,18 @@ async def lifespan(app: FastAPI): logger.info(f"Owner user '{OWNER_USERNAME}' is already verified") else: logger.warning(f"Owner user '{OWNER_USERNAME}' not found") - except Exception as e: logger.error(f"Failed to ensure owner verification: {e}") + logger.info( + "SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)", + POOL_CONFIG["pool_size"], + POOL_CONFIG["max_overflow"], + POOL_CONFIG["pool_timeout"], + POOL_CONFIG["pool_recycle"], + POOL_CONFIG["pool_pre_ping"], + ) + # Start the messaging cleanup task try: from routes.messaging import messagingManager @@ -66,6 +72,41 @@ async def lifespan(app: FastAPI): # Инициализация FastAPI app = FastAPI(title="FromChat", lifespan=lifespan) + +@app.middleware("http") +async def access_logging_middleware(request: Request, call_next): + start = time.perf_counter() + try: + response = await call_next(request) + except Exception as exc: + duration = time.perf_counter() - start + user = getattr(getattr(request, "state", None), "current_user", None) + log_access( + "http_error", + method=request.method, + path=request.url.path, + status="error", + user=getattr(user, "username", None), + ip=request.client.host if request.client else None, + duration=f"{duration:.3f}s", + error=str(exc), + ) + raise + else: + duration = time.perf_counter() - start + user = getattr(getattr(request, "state", None), "current_user", None) + log_access( + "http_request", + method=request.method, + path=request.url.path, + status=response.status_code, + user=getattr(user, "username", None), + ip=request.headers.get("x-forwarded-for") or (request.client.host if request.client else None), + duration=f"{duration:.3f}s", + ) + return response + + # CORS app.add_middleware( CORSMiddleware, @@ -89,4 +130,5 @@ app.include_router(messaging.router) app.include_router(profile.router) app.include_router(push.router, prefix="/push") app.include_router(webrtc.router, prefix="/webrtc") -app.include_router(devices.router, prefix="/devices") \ No newline at end of file +app.include_router(devices.router, prefix="/devices") +app.include_router(moderation.router) \ No newline at end of file diff --git a/backend/db.py b/backend/db.py index 1708283..a700933 100644 --- a/backend/db.py +++ b/backend/db.py @@ -6,5 +6,35 @@ from constants import DATABASE_URL # Ensure data directory exists os.makedirs("data", exist_ok=True) -engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20")) +MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40")) +POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800")) +POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30")) + +POOL_CONFIG = { + "pool_size": POOL_SIZE, + "max_overflow": MAX_OVERFLOW, + "pool_recycle": POOL_RECYCLE, + "pool_timeout": POOL_TIMEOUT, + "pool_pre_ping": True, +} + +engine_kwargs = { + "pool_size": POOL_SIZE, + "max_overflow": MAX_OVERFLOW, + "pool_recycle": POOL_RECYCLE, + "pool_pre_ping": True, + "pool_timeout": POOL_TIMEOUT, +} + +connect_args = {} +if DATABASE_URL.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + **engine_kwargs, +) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 3178e80..5bdfcab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session from utils import * @@ -17,8 +17,9 @@ def get_db(): # Зависимость для получения текущего пользователя def get_current_user( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ) -> User: token = credentials.credentials payload = verify_token(token) @@ -36,6 +37,12 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + if user.id == 1 and user.suspended: + user.suspended = False + user.suspension_reason = None + db.commit() + db.refresh(user) + # Validate device session from JWT session_id = payload.get("session_id") if not session_id: @@ -77,4 +84,7 @@ def get_current_user( detail="Account deleted", ) + request.state.current_user = user + request.state.session_id = session_id + return user \ No newline at end of file diff --git a/backend/logging_config.py b/backend/logging_config.py new file mode 100644 index 0000000..d8b28dd --- /dev/null +++ b/backend/logging_config.py @@ -0,0 +1,85 @@ +import logging +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path +from threading import RLock +from typing import Dict + +LOGS_DIR = Path(__file__).resolve().parent / "logs" +LOGS_DIR.mkdir(parents=True, exist_ok=True) + + +class HumanReadableFileHandler(RotatingFileHandler): + def __init__(self, filename: Path, level: int) -> None: + super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") + self.level = level + self._lock = RLock() + self._last_date: str | None = None + self._previous_entry: str | None = None + + def emit(self, record: logging.LogRecord) -> None: + try: + message = record.getMessage().strip() + if not message: + return + + timestamp = datetime.fromtimestamp(record.created) + date_str = timestamp.strftime("%d.%m.%Y") + time_str = timestamp.strftime("%H:%M:%S") + lines = [line.rstrip() for line in message.splitlines() if line.strip()] + + with self._lock: + if self._last_date != date_str: + if self._last_date is not None: + self.stream.write("\n") + separator = "-" * 11 + self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n") + self._last_date = date_str + + entry_lines: list[str] = [] + if lines: + entry_lines.append(f"{time_str} {lines[0]}") + for line in lines[1:]: + if line.startswith("|"): + entry_lines.append(f" {line}") + else: + entry_lines.append(f" ↳ {line}") + else: + entry_lines.append(time_str) + entry_text = "\n".join(entry_lines) + if entry_text == self._previous_entry: + return + self.stream.write(entry_text + "\n") + self._previous_entry = entry_text + self.flush() + except Exception: + self.handleError(record) + + +_HANDLED_FILES: Dict[str, Path] = {} + + +def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger: + logger = logging.getLogger(name) + target_path = LOGS_DIR / filename + + if _HANDLED_FILES.get(name) == target_path: + return logger + + logger.handlers.clear() + + handler = HumanReadableFileHandler(target_path, level) + handler.setLevel(level) + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = False + + _HANDLED_FILES[name] = target_path + return logger + + +security_logger = _configure_logger("security", "security.log") +public_chat_logger = _configure_logger("public_chat", "public-chat.log") +dm_logger = _configure_logger("dm", "dm.log") +access_logger = _configure_logger("access", "access.log") + diff --git a/backend/requirements.txt b/backend/requirements.txt index 3cf107f..eac7bc3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,5 @@ cryptography>=41.0.0 alembic>=1.13.2 better-profanity>=0.7.0 user-agents>=2.2.0 +httpx>=0.27.2 +rich>=13.9.4 diff --git a/backend/routes/account.py b/backend/routes/account.py index ff8cc26..7784c0f 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -1,4 +1,6 @@ from datetime import datetime +from collections import defaultdict, deque +import time from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from sqlalchemy import inspect, text @@ -8,14 +10,34 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from utils import create_token, get_password_hash, verify_password from validation import is_valid_password, is_valid_username, is_valid_display_name import os +from security.audit import log_security +from security.profanity import contains_profanity router = APIRouter() +_FAILED_ATTEMPT_WINDOW_SECONDS = 300 +_FAILED_ATTEMPT_THRESHOLD = 5 +_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque) + + +def _record_failed_login(identifier: str) -> bool: + now = time.time() + attempts = _failed_login_attempts[identifier] + attempts.append(now) + + while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS: + attempts.popleft() + + return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD + + +def _reset_failed_logins(identifier: str) -> None: + _failed_login_attempts.pop(identifier, None) + def convert_user(user: User) -> dict: return { "id": user.id, @@ -43,18 +65,51 @@ def check_auth(current_user: User = Depends(get_current_user)): @router.post("/login") -def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None): - user = db.query(User).filter(User.username == request.username.strip()).first() +def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): + username = request.username.strip() + x_forwarded_for = http.headers.get("x-forwarded-for") if http else None + client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None) + + user = db.query(User).filter(User.username == username).first() if not user or not verify_password(request.password.strip(), user.password_hash): + log_security( + "login_failed", + severity="warning", + username=username, + ip=client_ip, + reason="invalid_credentials", + ) + identifiers = [f"user:{username}"] + if client_ip: + identifiers.append(f"ip:{client_ip}") + + suspicious = False + for identifier in identifiers: + if _record_failed_login(identifier): + suspicious = True + + if suspicious: + total_failures = { + identifier: len(_failed_login_attempts.get(identifier, [])) + for identifier in identifiers + } + log_security( + "auth_bruteforce_detected", + severity="warning", + username=username, + ip=client_ip, + failures=total_failures, + window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS, + ) raise HTTPException( status_code=401, detail="Неверное имя пользователя или пароль" ) # Create device session and embed into JWT - raw_ua = http.headers.get("user-agent") if http else None - device_name = http.headers.get("x-device-name") if http else None + raw_ua = http.headers.get("user-agent") + device_name = http.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex @@ -82,6 +137,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = token = create_token(user.id, user.username, session_id) + identifiers = [f"user:{username}"] + if client_ip: + identifiers.append(f"ip:{client_ip}") + for identifier in identifiers: + _reset_failed_logins(identifier) + + log_security( + "login_success", + username=user.username, + user_id=user.id, + ip=client_ip, + session_id=session_id, + device=device.device_type, + os=device.os_name, + browser=device.browser_name, + ) + return { "status": "success", "message": "Login successful", @@ -91,11 +163,12 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = @router.post("/register") -def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None): +def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)): username = request.username.strip() display_name = request.display_name.strip() password = request.password.strip() confirm_password = request.confirm_password.strip() + client_ip = http.client.host if http.client else None # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None @@ -113,12 +186,22 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ 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( @@ -165,8 +248,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ db.refresh(new_user) # Create initial device session - raw_ua = http.headers.get("user-agent") if http else None - device_name = http.headers.get("x-device-name") if http else None + raw_ua = http.headers.get("user-agent") + device_name = http.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex device = DeviceSession( @@ -190,6 +273,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ token = create_token(new_user.id, new_user.username, session_id) + os_name = ua.os.family or "Unknown OS" + if ua.os.version_string: + os_name = f"{os_name} {ua.os.version_string}" + browser_name = ua.browser.family or "Unknown browser" + if ua.browser.version_string: + browser_name = f"{browser_name} {ua.browser.version_string}" + user_agent_summary = f"{os_name}, {browser_name}" + + log_security( + "registration_success", + username=new_user.username, + display_name=new_user.display_name, + user_id=new_user.id, + ip=client_ip, + user_agent=user_agent_summary, + owner=is_owner, + ) + return { "status": "success", "message": "Регистрация прошла успешно", @@ -264,10 +365,20 @@ def delete_user_as_owner( db.delete(user) db.commit() + log_security( + "admin_delete_user", + severity="warning", + actor=current_user.username, + actor_id=current_user.id, + target_username=user.username, + target_id=user.id, + ) + return {"status": "success", "deleted_user_id": user_id} @router.get("/logout") def logout( + http: Request, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) @@ -285,6 +396,15 @@ def logout( current_user.last_seen = datetime.now() db.commit() + client_ip = http.client.host if http.client else None + log_security( + "logout", + username=current_user.username, + user_id=current_user.id, + ip=client_ip, + session_id=payload.get("session_id") if payload else None, + ) + return { "status": "success", "message": "Logged out successfully" @@ -294,6 +414,7 @@ def logout( @router.post("/change-password") def change_password( request: ChangePasswordRequest, + http: Request, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) @@ -319,6 +440,15 @@ def change_password( ).update({DeviceSession.revoked: True}) db.commit() + client_ip = http.client.host if http.client else None + log_security( + "password_changed", + username=current_user.username, + user_id=current_user.id, + ip=client_ip, + logout_others=bool(request.logoutAllExceptCurrent), + ) + return {"status": "success"} @@ -430,6 +560,13 @@ async def delete_account( await _delete_user_data(current_user, db) + log_security( + "self_delete_account", + severity="warning", + user_id=current_user.id, + username=current_user.username, + ) + return { "status": "success", "message": "Account deleted successfully" diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 208af07..1f2d64d 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -1,4 +1,5 @@ from datetime import datetime +import html import logging from pathlib import Path import os @@ -6,6 +7,11 @@ import re import uuid import asyncio import time +import unicodedata +from collections import defaultdict, deque +from difflib import SequenceMatcher +from types import SimpleNamespace +from typing import Any from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials @@ -19,6 +25,8 @@ from PIL import Image import io import json from better_profanity import profanity as _bp +from security.audit import log_access, log_dm, log_public_chat, log_security +from security.profanity import censor_text router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -32,6 +40,110 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" os.makedirs(FILES_NORMAL_DIR, exist_ok=True) os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) +_SPAM_WINDOW_SECONDS = 45 +_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, 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() + + 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( + "public_message_burst", + severity="warning", + user_id=user.id, + username=user.username, + 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] + while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: + history.popleft() + + 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 + ) + + 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", + 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", + ) + def convert_message(msg: Message) -> dict: # Group reactions by emoji @@ -138,46 +250,6 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: ] } -# для тех кто читает этот код я эти маты не писал -# мат писал ии а я сам не матерюсь)) -# - denis0001-dev -_RU_EXTRA = [ - "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", - "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", - "долбоёб", "долбоеб", "дебил" -] - -_bp.load_censor_words() -_bp.add_censor_words(_RU_EXTRA) - -# Additional phrase-level filters (case-insensitive) -_PHRASE_PATTERNS: list[re.Pattern] = [ - re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), -] - -def _mask_span(text: str, start: int, end: int) -> str: - return text[:start] + ("\\*" * (end - start)) + text[end:] - -def _apply_phrase_filters(text: str) -> str: - result = text - for pattern in _PHRASE_PATTERNS: - # Replace all occurrences; iterate until no more matches to avoid overlapping issues - while True: - m = pattern.search(result) - if not m: - break - result = _mask_span(result, m.start(), m.end()) - return result - -def filter_profanity(text: str) -> str: - preprocessed = _apply_phrase_filters(text) - return _bp.censor(preprocessed, censor_char="\\*") - - @router.post("/send_message") async def send_message( request: SendMessageRequest | None = None, @@ -204,23 +276,26 @@ async def send_message( if not original_message: raise HTTPException(status_code=404, detail="Original message not found") - if not request.content.strip(): + raw_content = request.content.strip() + + if not raw_content: raise HTTPException( status_code=400, detail="No content provided" ) # Apply profanity filter before storing - filtered_content = filter_profanity(request.content.strip()) + filtered_content = censor_text(raw_content) + escaped_content = html.escape(filtered_content, quote=False) - if len(filtered_content) > 4096: + if len(escaped_content) > 4096: raise HTTPException( status_code=400, detail="Message too long" ) new_message = Message( - content=filtered_content, + content=escaped_content, user_id=current_user.id, reply_to_id=request.reply_to_id, timestamp=datetime.now() @@ -293,7 +368,6 @@ async def send_message( # Realtime broadcast for HTTP uploads as well try: - from .messaging import messagingManager # self import safe here await messagingManager.broadcast({ "type": "newMessage", "data": convert_message(new_message) @@ -301,7 +375,22 @@ async def send_message( except Exception: pass - return {"status": "success", "message": convert_message(new_message)} + _monitor_public_message_activity(current_user, filtered_content, db) + + message_payload = convert_message(new_message) + log_public_chat( + "message_created", + message_id=new_message.id, + user_id=current_user.id, + username=current_user.username, + reply_to=new_message.reply_to_id, + attachments=len(new_message.files or []), + length=len(new_message.content), + suspended=current_user.suspended, + content=new_message.content, + ) + + return {"status": "success", "message": message_payload} @router.get("/get_messages") @@ -404,6 +493,7 @@ async def dm_send( ) db.add(df) db.commit() + db.refresh(env) # Send push notification for DM try: @@ -433,6 +523,16 @@ async def dm_send( except Exception: pass + log_dm( + "message_sent", + dm_envelope_id=env.id, + sender_id=current_user.id, + sender_username=current_user.username, + recipient_id=env.recipient_id, + attachment_count=len(env.files or []), + reply_to=env.reply_to_id, + ) + return {"status": "ok", "id": env.id} def convert_envelopes(envs: list[DMEnvelope]): @@ -531,15 +631,35 @@ async def edit_message( raise HTTPException(status_code=404, detail="Message not found") if message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only edit your own messages") - if not request.content.strip(): + raw_content = request.content.strip() + + if not raw_content: raise HTTPException(status_code=400, detail="Message content cannot be empty") - message.content = request.content.strip() + + original_content = message.content + sanitized_content = censor_text(raw_content) + escaped_content = html.escape(sanitized_content, quote=False) + if len(escaped_content) > 4096: + raise HTTPException(status_code=400, detail="Message too long") + + message.content = escaped_content message.is_edited = True db.commit() db.refresh(message) - return {"status": "success", "message": convert_message(message)} + payload = convert_message(message) + log_public_chat( + "message_edited", + message_id=message.id, + user_id=current_user.id, + username=current_user.username, + reply_to=message.reply_to_id, + content=message.content, + previous_content=original_content, + ) + + return {"status": "success", "message": payload} @router.delete("/delete_message/{message_id}") @@ -557,9 +677,19 @@ async def delete_message( if current_user.username != OWNER_USERNAME and message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only delete your own messages") + original_content = message.content db.delete(message) db.commit() + log_public_chat( + "message_deleted", + message_id=message_id, + actor_id=current_user.id, + actor_username=current_user.username, + original_author_id=message.user_id, + content=original_content, + ) + return {"status": "success", "message_id": message_id} @@ -600,9 +730,10 @@ async def add_reaction( # Refresh message to get updated reactions db.refresh(message) + message_data = convert_message(message) + # Broadcast reaction update try: - from .messaging import messagingManager await messagingManager.broadcast({ "type": "reactionUpdate", "data": { @@ -611,13 +742,22 @@ async def add_reaction( "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_message(message)["reactions"] + "reactions": message_data["reactions"] } }) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]} + log_public_chat( + "reaction_update", + message_id=request.message_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=request.emoji, + ) + + return {"status": "success", "action": action, "reactions": message_data["reactions"]} @router.post("/dm/add_reaction") @@ -661,6 +801,8 @@ async def add_dm_reaction( # Refresh envelope to get updated reactions db.refresh(envelope) + envelope_data = convert_dm_envelope(envelope) + # Broadcast reaction update to both participants try: await messagingManager.broadcast({ @@ -671,13 +813,22 @@ async def add_dm_reaction( "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_dm_envelope(envelope)["reactions"] + "reactions": envelope_data["reactions"] } }) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]} + log_dm( + "reaction_update", + dm_envelope_id=request.dm_envelope_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=request.emoji, + ) + + return {"status": "success", "action": action, "reactions": envelope_data["reactions"]} class MessaggingSocketManager: @@ -697,13 +848,37 @@ class MessaggingSocketManager: # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() + ws_path = getattr(getattr(websocket, "url", None), "path", None) + if not ws_path and isinstance(getattr(websocket, "scope", None), dict): + ws_path = websocket.scope.get("path") + ws_path = ws_path or "unknown" + headers = {} + if isinstance(getattr(websocket, "scope", None), dict): + headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])} + xff = headers.get("x-forwarded-for") + client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None) + + def _log_ws(event: str, user: User | None, **extra: Any) -> None: + log_access( + "ws_event", + path=ws_path, + event=event, + user=user.username if user else None, + user_id=user.id if user else None, + ip=client_ip, + **extra, + ) + while True: data = await websocket.receive_json() type = data["type"] def get_current_user_inner() -> User | None: if data["credentials"]: + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() return get_current_user( + dummy_request, HTTPAuthorizationCredentials( scheme=data["credentials"]["scheme"], credentials=data["credentials"]["credentials"] @@ -714,6 +889,7 @@ class MessaggingSocketManager: return None if type == "ping": + current_user: User | None = None try: current_user = get_current_user_inner() if current_user: @@ -737,6 +913,7 @@ class MessaggingSocketManager: } } }) + _log_ws("ping_error", current_user) except HTTPException: await websocket.send_json({ "type": "ping", @@ -748,8 +925,11 @@ class MessaggingSocketManager: } } }) + _log_ws("ping_error", current_user) await websocket.send_json({"type": "ping", "data": {"status": "success"}}) + _log_ws("ping", current_user) elif type == "getMessages": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -757,9 +937,12 @@ class MessaggingSocketManager: self.user_by_ws[websocket] = current_user.id await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) + _log_ws("getMessages", current_user) except HTTPException as e: + _log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "sendMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -775,9 +958,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) except HTTPException as e: + _log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmSend": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -827,9 +1013,21 @@ class MessaggingSocketManager: await self.send_to_user(env.recipient_id, payload); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await self.send_to_user(env.sender_id, payload); + + _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) + log_dm( + "message_sent_ws", + dm_envelope_id=env.id, + sender_id=current_user.id, + sender_username=current_user.username, + recipient_id=env.recipient_id, + reply_to=env.reply_to_id, + ) except HTTPException as e: + _log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "editMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -845,9 +1043,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("editMessage", current_user, message_id=message_id) except HTTPException as e: + _log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmEdit": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -887,9 +1088,19 @@ class MessaggingSocketManager: await self.send_to_user(env.recipient_id, payload_ws) await self.send_to_user(env.sender_id, payload_ws) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) + + _log_ws("dmEdit", current_user, dm_envelope_id=env.id) + log_dm( + "message_edited", + dm_envelope_id=env.id, + user_id=current_user.id, + username=current_user.username, + ) except HTTPException as e: + _log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmDelete": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -917,9 +1128,20 @@ class MessaggingSocketManager: await self.send_to_user(env.recipient_id, payload_ws) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) await self.send_to_user(env.sender_id, payload_ws) + + _log_ws("dmDelete", current_user, dm_envelope_id=env_id) + log_dm( + "message_deleted", + dm_envelope_id=env_id, + user_id=current_user.id, + username=current_user.username, + recipient_id=env.recipient_id, + ) except HTTPException as e: + _log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "deleteMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -933,9 +1155,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("deleteMessage", current_user, message_id=message_id) except HTTPException as e: + _log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "addReaction": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -963,9 +1188,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) except HTTPException as e: + _log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "addDmReaction": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -993,10 +1221,13 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) except HTTPException as e: + _log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "call_signaling": # Forward WebRTC signaling between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1019,10 +1250,13 @@ class MessaggingSocketManager: # Optional ack await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) + _log_ws("call_signaling", current_user, to_user_id=to_user_id) except HTTPException as e: + _log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "call_video_toggle": # Forward video toggle state between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1049,9 +1283,13 @@ class MessaggingSocketManager: await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) elif type == "call_screen_share_toggle": # Forward screen share toggle state between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1078,8 +1316,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) elif type == "subscribeStatus": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1096,7 +1338,7 @@ class MessaggingSocketManager: "data": { "userId": user_id_to_subscribe, "online": target_user.online, - "lastSeen": target_user.last_seen.isoformat() + "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None } }) else: @@ -1105,8 +1347,12 @@ class MessaggingSocketManager: "data": {"status": "error", "error": "User not found"} }) except HTTPException as e: + _log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe) elif type == "unsubscribeStatus": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1117,8 +1363,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe) elif type == "typing": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1137,8 +1387,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("typing", current_user) elif type == "stopTyping": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1158,7 +1412,10 @@ class MessaggingSocketManager: await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("stopTyping", current_user) elif type == "dmTyping": try: current_user = get_current_user_inner() @@ -1219,11 +1476,25 @@ class MessaggingSocketManager: async def connect(self, websocket: WebSocket, db: Session): await websocket.accept() + client_ip = websocket.client.host if websocket.client else None + log_access( + "ws_connect", + path=str(websocket.url.path), + ip=client_ip, + ) self.connections.append(websocket) try: await self.handle_connection(websocket, db) except WebSocketDisconnect as e: logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") + log_access( + "ws_disconnect", + severity="warning" if e.code != 1000 else "info", + path=str(websocket.url.path), + ip=client_ip, + code=e.code, + reason=e.reason, + ) finally: # Cleanup connection self.connections.remove(websocket) diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py new file mode 100644 index 0000000..071432c --- /dev/null +++ b/backend/routes/moderation.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from typing import List + +from constants import OWNER_USERNAME +from dependencies import get_current_user +from models import User +from security.audit import log_security +from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist + + +class BlocklistUpdateRequest(BaseModel): + words: List[str] = Field(default_factory=list, min_items=1) + + +router = APIRouter(prefix="/moderation", tags=["moderation"]) + + +def _ensure_owner(user: User) -> None: + if user.username != OWNER_USERNAME: + raise HTTPException(status_code=403, detail="Only owner can perform this action") + + +@router.get("/blocklist") +def list_blocklist(current_user: User = Depends(get_current_user)): + _ensure_owner(current_user) + return {"words": get_blocklist()} + + +@router.post("/blocklist") +def append_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + added, updated = add_to_blocklist(request.words) + log_security( + "blocklist_add", + actor=current_user.username, + actor_id=current_user.id, + added=added, + ) + return {"added": added, "words": updated} + + +@router.delete("/blocklist") +def delete_from_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + removed, updated = remove_from_blocklist(request.words) + log_security( + "blocklist_remove", + actor=current_user.username, + actor_id=current_user.id, + removed=removed, + ) + return {"removed": removed, "words": updated} + diff --git a/backend/routes/profile.py b/backend/routes/profile.py index ccba31b..bcf5716 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -3,7 +3,6 @@ import re from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from sqlalchemy import inspect, text from PIL import Image import os import uuid @@ -15,9 +14,19 @@ from pydantic import BaseModel 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() + +def _ensure_owner_unsuspended(user: User | None, db: Session): + if user and user.id == 1 and user.suspended: + user.suspended = False + user.suspension_reason = None + db.commit() + db.refresh(user) + # Request models class UpdateProfileRequest(BaseModel): username: str | None = None @@ -104,15 +113,53 @@ async def get_user_profile( """ Get current user's profile information """ + _ensure_owner_unsuspended(current_user, db) + + return UserProfileResponse( + id=current_user.id, + username=current_user.username, + display_name=current_user.display_name, + profile_picture=current_user.profile_picture, + bio=current_user.bio, + online=current_user.online, + last_seen=current_user.last_seen, + created_at=current_user.created_at, + verified=current_user.verified, + suspended=current_user.suspended or False, + suspension_reason=current_user.suspension_reason, + deleted=current_user.deleted or False, + ) + + +@router.get("/user/list") +async def list_users( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only admin can list users") + + _ensure_owner_unsuspended(current_user, db) + + users = db.query(User).order_by(User.username.asc()).all() return { - "id": current_user.id, - "username": current_user.username, - "display_name": current_user.display_name, - "profile_picture": current_user.profile_picture, - "bio": current_user.bio, - "online": current_user.online, - "last_seen": current_user.last_seen, - "created_at": current_user.created_at + "users": [ + UserProfileResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at, + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=user.deleted or False, + ).model_dump() + for user in users + ] } @router.put("/user/profile") @@ -134,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() @@ -151,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 @@ -214,6 +271,8 @@ async def get_user_by_username( if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) return UserProfileResponse( id=user.id, @@ -223,7 +282,11 @@ async def get_user_by_username( bio=user.bio, online=user.online, last_seen=user.last_seen, - created_at=user.created_at + created_at=user.created_at, + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=user.deleted or False, ) @router.get("/user/id/{user_id}") @@ -238,6 +301,8 @@ async def get_user_by_id( if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) # Handle deleted users if user.deleted: @@ -293,6 +358,15 @@ async def verify_user( target_user.verified = not target_user.verified db.commit() + log_security( + "admin_verify_toggle", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + verified=target_user.verified, + ) + return { "verified": target_user.verified, "message": f"User verification {'enabled' if target_user.verified else 'disabled'}" @@ -363,6 +437,15 @@ async def suspend_user( target_user.suspension_reason = request.reason db.commit() + log_security( + "admin_suspend_user", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + reason=request.reason, + ) + # Send WebSocket suspension message try: await messagingManager.send_suspension_to_user(user_id, request.reason) @@ -399,6 +482,14 @@ async def unsuspend_user( target_user.suspension_reason = None db.commit() + log_security( + "admin_unsuspend_user", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + ) + return { "status": "success", "message": f"User {target_user.username} has been unsuspended" @@ -426,9 +517,22 @@ async def delete_user( if target_user.id == 1: raise HTTPException(status_code=400, detail="Cannot delete admin account") + snapshot_username = target_user.username + snapshot_display_name = target_user.display_name + from .account import _delete_user_data await _delete_user_data(target_user, db) + log_security( + "admin_delete_user", + severity="warning", + actor=current_user.username, + actor_id=current_user.id, + target_username=snapshot_username, + target_display_name=snapshot_display_name, + target_id=target_user.id, + ) + return { "status": "success", "message": f"User {target_user.username} has been deleted" diff --git a/backend/security/__init__.py b/backend/security/__init__.py new file mode 100644 index 0000000..9429562 --- /dev/null +++ b/backend/security/__init__.py @@ -0,0 +1,2 @@ +# Package marker for security utilities + diff --git a/backend/security/audit.py b/backend/security/audit.py new file mode 100644 index 0000000..acf8766 --- /dev/null +++ b/backend/security/audit.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import logging +from html import unescape +from typing import Any, Callable, Dict, List + +from logging_config import access_logger, dm_logger, public_chat_logger, security_logger + + +def _clean_username(username: Any) -> str: + if not username: + return "unknown user" + return f"@{username}" + + +def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str: + username = fields.get(username_key) + if username is None and "_" in username_key: + base_key = username_key.split("_", 1)[0] + username = fields.get(base_key) + + user_id = fields.get(user_id_key) + if user_id is None and "_" in user_id_key: + base_key = user_id_key.split("_", 1)[0] + user_id = fields.get(base_key) + + if username and user_id is not None: + return f"{_clean_username(username)} (user id {user_id})" + if username: + return _clean_username(username) + if user_id is not None: + return f"user id {user_id}" + return "unknown user" + + +def _format_actor(fields: Dict[str, Any], prefix: str) -> str: + return _format_user(fields, f"{prefix}_username", f"{prefix}_id") + + +def _plural(label: str, count: int) -> str: + return f"{count} {label if count == 1 else label + 's'}" + + +def _yes_no(flag: Any) -> str: + return "yes" if flag else "no" + + +def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: + if action == "login_success": + lines = [f"Login approved for {_format_user(fields)}"] + session = fields.get("session_id") + if session: + lines.append(f"Session: {session}") + client_bits: List[str] = [] + if fields.get("device"): + client_bits.append(fields["device"]) + if fields.get("os"): + client_bits.append(fields["os"]) + if fields.get("browser"): + client_bits.append(fields["browser"]) + if client_bits: + lines.append(f"Client: {', '.join(client_bits)}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "login_failed": + lines = [f"Login denied for {_format_user(fields)}"] + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "auth_bruteforce_detected": + lines = ["Brute-force login pattern detected"] + lines.append(f"Target: {_format_user(fields)}") + failures = fields.get("failures") + if isinstance(failures, dict): + for key, value in failures.items(): + lines.append(f"{key}: {value}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + if fields.get("window_seconds"): + lines.append(f"Observation window: {fields['window_seconds']} seconds") + return lines + if action == "registration_success": + ip_raw = fields.get("ip") + ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw + display_name = fields.get("display_name") or "Unknown" + username = fields.get("username") + user_id = fields.get("user_id") + user_agent = fields.get("user_agent") or "Unknown user agent" + lines = ["Account registered"] + lines.append(f"Display name: {display_name}") + lines.append(f"Username: {_clean_username(username) if username else 'unknown'}") + if ip_display: + lines.append(f"IP: {ip_display}") + if user_agent: + lines.append(f"User agent: {user_agent}") + if user_id is not None: + lines.append(f"User ID: {user_id}") + return lines + if action == "password_changed": + lines = [f"Password changed for {_format_user(fields)}"] + lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "logout": + lines = [f"Logout recorded for {_format_user(fields)}"] + if fields.get("session_id"): + lines.append(f"Session: {fields['session_id']}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "admin_delete_user": + return [ + "Account removal", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if action == "admin_suspend_user": + lines = [ + "User suspension", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if fields.get("reason"): + lines.append(f"Reason: {fields.get('reason')}") + return lines + if action == "admin_unsuspend_user": + return [ + "User unsuspension", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if action == "admin_verify_toggle": + return [ + "User verification", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + f"Verified: {_yes_no(fields.get('verified'))}", + ] + if action == "self_delete_account": + return [f"User {_format_user(fields)} deleted their account"] + if action == "auto_suspension_public_spam": + lines = [ + f"Automatic suspension triggered for {_format_user(fields)}", + ] + 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 [ + f"Rapid messaging spike for {_format_user(fields)}", + f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", + ] + if action == "blocklist_add": + added = fields.get("added") or [] + lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"] + if added: + lines.append(f"Added entries: {', '.join(added)}") + total = len(fields.get("words") or []) + lines.append(f"Total entries: {total}") + return lines + if action == "blocklist_remove": + removed = fields.get("removed") or [] + lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"] + if removed: + lines.append(f"Removed entries: {', '.join(removed)}") + total = len(fields.get("words") or []) + lines.append(f"Total entries: {total}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: + if action == "message_created": + lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"] + if fields.get("reply_to"): + lines.append(f"In reply to message #{fields['reply_to']}") + attachments = fields.get("attachments") + if attachments: + lines.append(f"Attachments: {_plural('file', attachments)}") + if fields.get("content"): + lines.append("Content:") + for line in unescape(fields["content"]).splitlines(): + lines.append(f"| {line}") + return lines + if action == "message_edited": + lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"] + if fields.get("reply_to"): + lines.append(f"Reply to #{fields['reply_to']}") + if fields.get("previous_content"): + lines.append("Previous content:") + for line in unescape(fields["previous_content"] or "").splitlines() or [""]: + lines.append(f"| {line}") + if fields.get("content"): + lines.append("New content:") + for line in unescape(fields["content"] or "").splitlines() or [""]: + lines.append(f"| {line}") + + return lines + if action == "message_deleted": + lines = [ + f"Message #{fields.get('message_id')} deleted", + f"Actor: {_format_actor(fields, 'actor')}", + ] + if fields.get("original_author_id") is not None: + lines.append(f"Original author: user #{fields['original_author_id']}") + if fields.get("content"): + lines.append("↳ Previous content:") + for line in unescape(fields["content"]).splitlines(): + lines.append(f"| {line}") + return lines + if action == "reaction_update": + lines = [ + f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}", + f"User: {_format_user(fields)}", + ] + if fields.get("emoji"): + lines.append(f"Emoji: {fields['emoji']}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]: + if action in {"message_sent", "message_sent_ws"}: + lines = [ + f"Direct message #{fields.get('dm_envelope_id')} sent", + f"Sender: {_format_actor(fields, 'sender')}", + ] + if fields.get("recipient_id") is not None: + lines.append(f"Recipient: user id {fields['recipient_id']}") + attachments = fields.get("attachment_count") + if attachments: + lines.append(f"Attachments: {_plural('file', attachments)}") + if fields.get("reply_to"): + lines.append(f"In reply to DM #{fields['reply_to']}") + return lines + if action == "message_edited": + return [ + f"Direct message #{fields.get('dm_envelope_id')} edited", + f"Author: {_format_user(fields)}", + ] + if action == "message_deleted": + lines = [ + f"Direct message #{fields.get('dm_envelope_id')} deleted", + f"Actor: {_format_user(fields)}", + ] + if fields.get("recipient_id") is not None: + lines.append(f"Recipient: user id {fields['recipient_id']}") + return lines + if action == "reaction_update": + lines = [ + f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}", + f"User: {_format_user(fields)}", + ] + if fields.get("emoji"): + lines.append(f"Emoji: {fields['emoji']}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_access(action: str, fields: Dict[str, Any]) -> List[str]: + ip_raw = fields.get("ip") + ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw + if action == "http_request": + first_line = f"{fields.get('method')} {fields.get('path')}" + if ip_display: + first_line += f" from {ip_display}" + first_line += f" -> {fields.get('status')}" + lines = [first_line] + if fields.get("user"): + lines.append(f"Authenticated user: {_clean_username(fields['user'])}") + return lines + if action == "http_error": + first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}" + if ip_display: + first_line += f" from {ip_display}" + lines = [first_line] + if fields.get("error"): + lines.append(f"Exception: {fields['error']}") + if fields.get("user"): + lines.append(f"Authenticated user: {_clean_username(fields['user'])}") + return lines + if action == "ws_connect": + lines = ["WebSocket connected"] + if fields.get("path"): + lines.append(f"Endpoint: {fields['path']}") + if ip_display: + lines.append(f"IP: {ip_display}") + return lines + if action == "ws_disconnect": + lines = ["WebSocket disconnected"] + if fields.get("path"): + lines.append(f"Endpoint: {fields['path']}") + if fields.get("code") is not None: + reason = fields.get("reason") or "no reason" + lines.append(f"Code {fields['code']} ({reason})") + if ip_display: + lines.append(f"IP: {ip_display}") + return lines + if action == "ws_event": + event_name = fields.get("event") + path = fields.get("path") + first_line = "WS" + if path: + first_line += f" {path}" + if ip_display: + first_line += f" from {ip_display}" + if event_name: + first_line += f" -> {event_name}" + lines = [first_line] + if fields.get("user"): + lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}") + for key, value in fields.items(): + if key in {"path", "event", "user", "user_id", "ip"} or value is None: + continue + lines.append(f"{key.replace('_', ' ').capitalize()}: {value}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _log_event( + logger: logging.Logger, + renderer: Callable[[str, Dict[str, Any]], List[str]], + action: str, + severity: str, + fields: Dict[str, Any], +) -> None: + lines = renderer(action, fields) + if not lines: + return + level = getattr(logging, severity.upper(), logging.INFO) + logger.log(level, "\n".join(lines)) + + +def log_security(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(security_logger, _render_security, action, severity, fields) + + +def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(public_chat_logger, _render_public_chat, action, severity, fields) + + +def log_dm(action: str, severity: str = "info", **fields: Any) -> None: + sanitized_fields = {key: value for key, value in fields.items() if key != "content"} + _log_event(dm_logger, _render_dm, action, severity, sanitized_fields) + + +def log_access(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(access_logger, _render_access, action, severity, fields) + diff --git a/backend/security/profanity.py b/backend/security/profanity.py new file mode 100644 index 0000000..8c49d30 --- /dev/null +++ b/backend/security/profanity.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from threading import RLock +from typing import Iterable, List, Set, Tuple + +from better_profanity import Profanity + +BLOCKLIST_PATH = Path("data/profanity/blocklist.json") +BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) + +_CUSTOM_RU_TERMS: Set[str] = { + "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", + "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", + "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", + "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", + "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", + "пидоры", "пидорас", "пидорасы", "пидорасов", +} + +_ADULT_TERMS: Set[str] = { + "порно", "порнуха", "эротика", "эротический", "секс", "сексуальный", + "инцест", "порнография", "порностудия", "порновидео", "порносайт", + "сексчат", "сексчатик", "секслайв", "сексвидео", +} + +_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) + +_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( + re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bfromchat\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"\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() + + +def _normalize_words(words: Iterable[str]) -> Set[str]: + normalized: Set[str] = set() + for raw in words: + if not raw: + continue + cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower() + if cleaned: + normalized.add(cleaned) + return normalized + + +def _load_blocklist() -> Set[str]: + if not BLOCKLIST_PATH.exists(): + return set() + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + return _normalize_words(data) + except Exception: + pass + return set() + + +def _write_blocklist(words: Iterable[str]) -> None: + BLOCKLIST_PATH.write_text( + json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8" + ) + + +def _rebuild_dictionary(force: bool = False) -> None: + global _profanity, _blocklist_signature + with _dictionary_lock: + blocklist_list = sorted(_load_blocklist()) + signature = tuple(blocklist_list) + if not force and _blocklist_signature == signature and _blocklist_signature is not None: + return + + profanity = Profanity() + profanity.load_censor_words() + combined = set(_STATIC_TERMS) + combined.update(blocklist_list) + if combined: + profanity.add_censor_words(list(combined)) + + _profanity = profanity + _blocklist_signature = signature + + +def _apply_phrase_filters(text: str) -> str: + result = text + for pattern in _PHRASE_PATTERNS: + while True: + match = pattern.search(result) + if not match: + break + 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 + + +def censor_text(text: str) -> str: + if not text: + return text + + _rebuild_dictionary() + preprocessed = _apply_phrase_filters(text) + 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()) + + +def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: + normalized = _normalize_words(words) + if not normalized: + return [], get_blocklist() + + with _dictionary_lock: + current = _load_blocklist() + added = sorted(normalized - current) + if not added: + return [], sorted(current) + + updated = sorted(current | normalized) + _write_blocklist(updated) + _rebuild_dictionary(force=True) + return added, updated + + +def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: + normalized = _normalize_words(words) + if not normalized: + return [], get_blocklist() + + with _dictionary_lock: + current = _load_blocklist() + removed = sorted(word for word in normalized if word in current) + if not removed: + return [], sorted(current) + + updated = sorted(current - normalized) + _write_blocklist(updated) + _rebuild_dictionary(force=True) + return removed, updated + 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 diff --git a/deployment/Dockerfile.backend b/deployment/Dockerfile.backend index daf7c5b..61e3e1f 100644 --- a/deployment/Dockerfile.backend +++ b/deployment/Dockerfile.backend @@ -14,12 +14,15 @@ FROM python:3.12-slim AS runtime WORKDIR /app RUN useradd -u 1000 app && \ chown -R app /app -USER app # 2.2. Copy content and create dirs COPY --chown=app backend . COPY --from=builder --chown=app /app/.venv .venv -RUN mkdir -p /app/data +RUN mkdir -p /app/data && \ + printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \ + chmod +x /usr/local/bin/admin-cli + +USER app # 3. Final command ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py \ No newline at end of file diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index a0cd804..de7e115 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -9,7 +9,9 @@ services: VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} volumes: - - "data:/app/data" + - data:/app/data + - logs:/app/logs + develop: watch: - action: sync+restart @@ -41,4 +43,6 @@ services: volumes: data: - name: fromchat-data \ No newline at end of file + name: fromchat-data + logs: + name: fromchat-logs \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 9423457..757cdd9 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -3,7 +3,7 @@ import type { Attachment, Message as MessageType, Reaction } from "@/core/types" import defaultAvatar from "@/images/default-avatar.png"; import Quote from "@/core/components/Quote"; import { parse } from "marked"; -import DOMPurify from "dompurify"; +import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; import { getCurrentKeys } from "@/core/api/authApi"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; @@ -170,7 +170,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD const formattedMessage = useMemo(() => { // First, temporarily replace existing fromchat.ru links to avoid conflicts const linkPlaceholders: string[] = []; - let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { + let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; linkPlaceholders.push(match); return placeholder; @@ -186,8 +186,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); }); + const rendered = parse(content, { async: false }).trim(); + return { - __html: DOMPurify.sanitize(parse(content, { async: false })).trim() + __html: rendered }; }, [message.content, styles.mentionLink]); diff --git a/package.json b/package.json index f8f8362..d59d2b4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@electron-forge/plugin-auto-unpack-natives": "^7.9.0", "@electron-forge/plugin-fuses": "^7.9.0", "@electron/fuses": "^1.0.0", + "@types/he": "^1.2.3", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", @@ -64,9 +65,9 @@ "vite-plugin-sass-dts": "^1.3.34" }, "dependencies": { - "dompurify": "^3.2.7", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", + "he": "^1.2.0", "marked": "^16.3.0", "mdui": "^2.1.4", "motion": "^12.23.24",