fix(security): comprehensive security hardening — TLS, HMAC WS auth, rate limiting, IP leak prevention

CRITICAL fixes:
- Auto-generated self-signed TLS certs (HTTPS/WSS by default)
- Removed session_key from /srp/verify response (was sent in plaintext)
- Replaced with HMAC-SHA256 ws_token for WebSocket authentication

HIGH fixes:
- WebSocket auth now validates ws_token via hmac.compare_digest()
- /clear endpoint requires Bearer admin_token (printed at server start)
- Password no longer required as CLI arg — supports env var + getpass prompt
- Removed user_ip from Message model (no longer broadcast to clients)

MEDIUM fixes:
- Rate limiter on /srp/init and /srp/verify (10 req/min/IP)
- MessageStore capped at 1000 messages (prevents RAM DoS)
- access_log disabled (was leaking request metadata)

LOW fixes:
- Username sanitization against rich markup injection
- Dead code removed from helpers.py

All 79 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-25 20:30:40 -07:00
parent 440b67da26
commit e7bacc93da
11 changed files with 255 additions and 80 deletions
+17 -27
View File
@@ -1,36 +1,21 @@
import time
from collections import defaultdict
from datetime import datetime, timezone
from typing import Optional
from dataclasses import asdict
import json
from sanic import Sanic, Request, response, Websocket
from sanic import Request, Sanic, Websocket
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def verify_password(password: Optional[str], expected: Optional[str]) -> bool:
if not expected:
return True
return password == expected
def get_client_ip(request: Request) -> str:
if forwarded := request.headers.get("x-forwarded-for"):
return forwarded.split(",")[0].strip()
return request.ip
def get_param(request: Request, name: str) -> Optional[str]:
return request.args.get(name) or request.form.get(name)
def require_auth(request: Request, app: Sanic) -> Optional[response.HTTPResponse]:
if not verify_password(get_param(request, "password"), app.ctx.admin_password):
return response.text("Unauthorized", status=401)
return None
async def send_state(ws: Websocket, app: Sanic) -> None:
messages = app.ctx.message_store.get_all()
users = app.ctx.session_store.get_all()
@@ -47,12 +32,17 @@ async def send_state(ws: Websocket, app: Sanic) -> None:
)
def extract_pubkey(request: Request) -> Optional[bytes]:
if files := request.files.get("pubkey"):
file = files[0] if isinstance(files, list) else files
return file.body
if raw := request.form.get("pubkey"):
return raw.encode() if isinstance(raw, str) else raw
if raw := request.args.get("pubkey"):
return raw.encode() if isinstance(raw, str) else raw
return None
class RateLimiter:
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
self.max_requests = max_requests
self.window = window_seconds
self._requests: dict[str, list[float]] = defaultdict(list)
def is_allowed(self, key: str) -> bool:
now = time.monotonic()
timestamps = self._requests[key]
timestamps[:] = [t for t in timestamps if now - t < self.window]
if len(timestamps) >= self.max_requests:
return False
timestamps.append(now)
return True