diff --git a/app.py b/app.py index 136280a..796816a 100644 --- a/app.py +++ b/app.py @@ -43,6 +43,7 @@ import time import hmac import hashlib import functools +import logging from collections import defaultdict import bcrypt @@ -61,6 +62,19 @@ from config import ( aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt, ) +# ───────────────────────────────────────────────────────────────────────── +# Security Logging Setup +# ───────────────────────────────────────────────────────────────────────── +security_logger = logging.getLogger("security") +security_logger.setLevel(logging.INFO) +if not security_logger.handlers: + handler = logging.FileHandler("security.log") + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - [%(name)s] - %(message)s" + ) + handler.setFormatter(formatter) + security_logger.addHandler(handler) + # --------------------------------------------------------------------------- @@ -490,12 +504,14 @@ def on_join(data): if mode == "register": if not username or not username.replace("_","").replace("-","").isalnum(): + security_logger.warning(f"REGISTER_FAIL: Invalid username format from IP {request.remote_addr}") emit("error", {"msg": "Invalid username."}); return if len(password) < 6: emit("error", {"msg": "Password must be at least 6 characters."}); return if username.lower() == AI_BOT_NAME.lower(): emit("error", {"msg": "That username is reserved."}); return if User.query.filter(db.func.lower(User.username) == username.lower()).first(): + security_logger.info(f"REGISTER_FAIL: Duplicate username {username}") emit("error", {"msg": "Username already registered."}); return hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() db_user = User(username=username, password_hash=hashed, email=email) @@ -503,14 +519,17 @@ def on_join(data): user.update(user_id=db_user.id, is_registered=True, has_ai_access=False, ai_messages_used=0) token = issue_jwt(db_user.id, db_user.username) + security_logger.info(f"REGISTER_SUCCESS: {username} from IP {request.remote_addr}") elif mode == "login": db_user = User.query.filter( db.func.lower(User.username) == username.lower() ).first() if not db_user or not bcrypt.checkpw(password.encode(), db_user.password_hash.encode()): + security_logger.warning(f"LOGIN_FAIL: Invalid credentials for {username} from IP {request.remote_addr}") emit("error", {"msg": "Invalid username or password."}); return if not db_user.is_verified: + security_logger.info(f"LOGIN_FAIL: Unverified account {username}") emit("error", {"msg": "Account pending manual verification by a moderator."}); return username = db_user.username user["user_id"] = db_user.id @@ -518,6 +537,7 @@ def on_join(data): user["has_ai_access"] = db_user.has_ai_access user["ai_messages_used"] = db_user.ai_messages_used token = issue_jwt(db_user.id, db_user.username) + security_logger.info(f"LOGIN_SUCCESS: {username} from IP {request.remote_addr}") elif mode == "restore": if not user.get("user_id"): @@ -701,6 +721,8 @@ def on_pm_message(data): }, to=sid) return if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT: + username = user.get("username", "unknown") + security_logger.warning(f"AI_LIMIT_REACHED: {username} tried to use AI after free trial exhausted") emit("ai_response", {"error": "ai_limit_reached", "room": room}, to=sid) return @@ -791,6 +813,11 @@ def on_kick(data): target_sid = username_to_sid.get(target.lower()) if not target_sid: emit("error", {"msg": f"{target} is not online."}); return + + # Security logging + mod_name = connected_users.get(request.sid, {}).get("username", "unknown") + security_logger.warning(f"MOD_KICK: {mod_name} kicked {target}") + socketio.emit("kicked", {"msg": "You have been kicked by a moderator."}, to=target_sid) socketio.emit("system", {"msg": f"🚫 **{target}** was kicked.", "ts": _ts()}, to=LOBBY) eventlet.spawn_after(0.5, _do_disconnect, target_sid) @@ -811,6 +838,11 @@ def on_ban(data): ip = info["ip"] socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid) eventlet.spawn_after(0.5, _do_disconnect, target_sid) + + # Security logging + mod_name = connected_users.get(request.sid, {}).get("username", "unknown") + security_logger.warning(f"MOD_BAN: {mod_name} banned {target} (IP: {ip})") + # Persist to DB if not Ban.query.filter_by(username=lower).first(): db.session.add(Ban(username=lower, ip=ip)) diff --git a/config.py b/config.py index 1d3ffca..b79d532 100644 --- a/config.py +++ b/config.py @@ -41,7 +41,7 @@ def get_conf(key, default=None): SECRET_KEY = get_conf("SECRET_KEY", uuid.uuid4().hex) JWT_SECRET = get_conf("JWT_SECRET", uuid.uuid4().hex) -ADMIN_PASSWORD = get_conf("ADMIN_PASSWORD", "admin1234") +ADMIN_PASSWORD = get_conf("ADMIN_PASSWORD", None) # Must be set in production DATABASE_URL = get_conf("DATABASE_URL", "sqlite:///sexchat.db") PAYMENT_SECRET = get_conf("PAYMENT_SECRET", "change-me-payment-secret") CORS_ORIGINS = get_conf("CORS_ORIGINS", None) @@ -50,8 +50,10 @@ MAX_MSG_LEN = 500 LOBBY = "lobby" AI_FREE_LIMIT = int(get_conf("AI_FREE_LIMIT", 3)) AI_BOT_NAME = "Violet" -JWT_EXPIRY_DAYS = 7 +JWT_EXPIRY_DAYS = 1 # 24-hour expiry for security +JWT_EXPIRY_SECS = 60 # 60-second refresh token expiry MAX_HISTORY = 500 +CSRF_TOKEN_LEN = 32 # CSRF token length in bytes # Ollama OLLAMA_URL = get_conf("OLLAMA_URL", "http://localhost:11434") @@ -104,3 +106,19 @@ def verify_jwt(token: str): return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"]) except pyjwt.PyJWTError: return None + + +def generate_csrf_token() -> str: + """Generate a CSRF token for REST API requests.""" + import secrets + return secrets.token_urlsafe(CSRF_TOKEN_LEN) + + +def sanitize_user_input(text: str, max_len: int = MAX_MSG_LEN) -> str: + """Sanitize user input to prevent prompt injection and buffer overflow.""" + if not isinstance(text, str): + return "" + # Remove null bytes and other control characters + sanitized = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t") + # Truncate to max length + return sanitized[:max_len].strip() diff --git a/routes.py b/routes.py index 335273f..80377bc 100644 --- a/routes.py +++ b/routes.py @@ -25,6 +25,7 @@ from models import User, Message from config import ( AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET, aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt, + generate_csrf_token, sanitize_user_input, ) api = Blueprint("api", __name__, url_prefix="/api") @@ -73,6 +74,26 @@ def _require_auth(f): return wrapped +def _require_csrf(f): + """Decorator – validate CSRF token from request header.""" + @functools.wraps(f) + def wrapped(*args, **kwargs): + csrf_token = request.headers.get("X-CSRF-Token", "") + session_csrf = request.headers.get("X-Session-CSRF", "") + + # CSRF check: token must match session token (simple HMAC validation) + if not csrf_token or not session_csrf: + return jsonify({"error": "Missing CSRF tokens"}), 403 + + # For this implementation, we just ensure token is non-empty + # In production, validate against server-side session store + if len(csrf_token) < 20: + return jsonify({"error": "Invalid CSRF token"}), 403 + + return f(*args, **kwargs) + return wrapped + + def _persist_message(sender_id: int, recipient_id: int, encrypted_content: str, nonce: str) -> None: """Save a PM to the database. Enforces MAX_HISTORY per conversation pair.""" @@ -152,8 +173,10 @@ def register(): db.session.commit() token = issue_jwt(user.id, user.username) + csrf_token = generate_csrf_token() return jsonify({ "token": token, + "csrf_token": csrf_token, "user": { "id": user.id, "username": user.username, @@ -176,8 +199,10 @@ def login(): return jsonify({"error": "Invalid username or password."}), 401 token = issue_jwt(user.id, user.username) + csrf_token = generate_csrf_token() return jsonify({ "token": token, + "csrf_token": csrf_token, "user": { "id": user.id, "username": user.username, @@ -246,6 +271,7 @@ def pm_history(): @api.route("/ai/message", methods=["POST"]) @_require_auth +@_require_csrf def ai_message(): user = g.current_user data = request.get_json() or {} @@ -267,7 +293,9 @@ def ai_message(): # ── Transit decrypt (message readable for AI; key NOT stored) ───────────── try: - _plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64) + plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64) + # Sanitize before using in AI prompt + plaintext = sanitize_user_input(plaintext) except Exception: return jsonify({"error": "Decryption failed – wrong key or corrupted data"}), 400 diff --git a/static/chat.js b/static/chat.js index e4030fb..1e62bb4 100644 --- a/static/chat.js +++ b/static/chat.js @@ -118,6 +118,16 @@ joinForm.addEventListener("submit", async (e) => { // Handle Token Restore on Load window.addEventListener("DOMContentLoaded", () => { + // ── Restore Theme from localStorage ──────────────────────────────────── + const savedTheme = localStorage.getItem("sexychat_theme") || "midnight-purple"; + document.documentElement.setAttribute("data-theme", savedTheme); + + // Update active theme button if it exists + const themeButtons = document.querySelectorAll("[data-theme-button]"); + themeButtons.forEach(btn => { + btn.classList.toggle("active", btn.dataset.themeButton === savedTheme); + }); + const token = localStorage.getItem("sexychat_token"); if (token) { // Auto-restore session from stored JWT