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:
+17
-27
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user