Some checks are pending
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
M1: enforce the declared transfer size (clamped to MAX_SIZE) on chunk receipt in both the Rust and Python clients — a malicious sender can no longer grow the receive buffer unboundedly. M2: only honor X-Forwarded-For when TRUST_PROXY is set, so a direct client can't spoof a source IP to dodge the per-IP rate limiter. M3: evict unverified SRP sessions after a 60s TTL on each new handshake, preventing half-finished auths from exhausting memory. M4: drop WS frames larger than 256 KB before they hit the store or broadcast, bounding per-message memory and flood blast radius. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
7.2 KiB
Python
225 lines
7.2 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
import base64
|
|
from dataclasses import asdict
|
|
|
|
from sanic import Sanic, Request, response, Websocket
|
|
from sanic.response import HTTPResponse, json as json_response
|
|
|
|
from .models import Message, UserSession
|
|
from .helpers import get_client_ip, send_state, utcnow
|
|
|
|
|
|
# Hard cap on a single relayed WS frame. The largest legitimate frame is one
|
|
# Fernet-encrypted 64 KB file chunk (~120 KB after base64 + token overhead), so
|
|
# 256 KB leaves headroom while bounding per-message memory and the 1000-message
|
|
# store. Oversized frames are dropped, not stored or broadcast.
|
|
MAX_FRAME_SIZE = 256 * 1024
|
|
|
|
|
|
def generate_ws_token(user_id: str, secret: bytes) -> str:
|
|
return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def _roster_frame(app: Sanic) -> str:
|
|
"""Authoritative presence snapshot — all clergy members converge on this."""
|
|
users = app.ctx.session_store.get_all()
|
|
return json.dumps(
|
|
{
|
|
"type": "roster",
|
|
"users": [{"user_id": u.user_id, "username": u.username} for u in users],
|
|
"capacity": app.ctx.max_users,
|
|
}
|
|
)
|
|
|
|
|
|
async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
if not app.ctx.rate_limiter.is_allowed(client_ip):
|
|
return response.json({"error": "Rate limited"}, status=429)
|
|
|
|
data = request.json or {}
|
|
username = data.get("username", "unknown")
|
|
client_public_b64 = data.get("A")
|
|
|
|
if not client_public_b64:
|
|
return response.json({"error": "Missing A"}, status=400)
|
|
|
|
client_public = base64.b64decode(client_public_b64)
|
|
|
|
if app.ctx.session_store.username_exists(username):
|
|
return response.json({"error": "Username taken"}, status=409)
|
|
|
|
if app.ctx.session_store.count() >= app.ctx.max_users:
|
|
return response.json({"error": "Clergy full"}, status=409)
|
|
|
|
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
|
|
|
|
return response.json(
|
|
{
|
|
"user_id": user_id,
|
|
"B": base64.b64encode(B).decode(),
|
|
"salt": base64.b64encode(salt).decode(),
|
|
"room_salt": base64.b64encode(app.ctx.room_salt).decode(),
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
return response.json({"error": "SRP init failed"}, status=500)
|
|
|
|
|
|
async def srp_verify(request: Request, app: Sanic) -> HTTPResponse:
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
if not app.ctx.rate_limiter.is_allowed(client_ip):
|
|
return response.json({"error": "Rate limited"}, status=429)
|
|
|
|
data = request.json or {}
|
|
user_id = data.get("user_id")
|
|
client_proof_b64 = data.get("M")
|
|
username = data.get("username", "unknown")
|
|
|
|
if not user_id or not client_proof_b64:
|
|
return response.json({"error": "Missing user_id or M"}, status=400)
|
|
|
|
client_proof = base64.b64decode(client_proof_b64)
|
|
|
|
# Authoritative capacity gate — the slot is only consumed once a session
|
|
# is actually added here (init is best-effort / racy).
|
|
if app.ctx.session_store.count() >= app.ctx.max_users:
|
|
return response.json({"error": "Clergy full"}, status=409)
|
|
|
|
H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof)
|
|
|
|
fernet_key = base64.urlsafe_b64encode(session_key[:32])
|
|
|
|
session = UserSession(
|
|
user_id=user_id,
|
|
ip=get_client_ip(request),
|
|
username=username,
|
|
fernet_key=fernet_key,
|
|
)
|
|
app.ctx.session_store.add(session)
|
|
|
|
ws_token = generate_ws_token(user_id, app.ctx.ws_secret)
|
|
|
|
return response.json(
|
|
{
|
|
"H_AMK": base64.b64encode(H_AMK).decode(),
|
|
"ws_token": ws_token,
|
|
}
|
|
)
|
|
|
|
except ValueError as e:
|
|
return response.json({"error": str(e)}, status=401)
|
|
except Exception:
|
|
return response.json({"error": "SRP verify failed"}, status=500)
|
|
|
|
|
|
async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
|
|
user_id = request.args.get("user_id")
|
|
ws_token = request.args.get("ws_token")
|
|
|
|
if not user_id or not ws_token:
|
|
await ws.close(code=4002, reason="user_id and ws_token required")
|
|
return
|
|
|
|
expected_token = generate_ws_token(user_id, app.ctx.ws_secret)
|
|
if not hmac.compare_digest(ws_token, expected_token):
|
|
await ws.close(code=4003, reason="Invalid token")
|
|
return
|
|
|
|
session = app.ctx.session_store.get(user_id)
|
|
if not session:
|
|
await ws.close(code=4002, reason="Invalid session")
|
|
return
|
|
|
|
manager = app.ctx.connection_manager
|
|
await manager.connect(user_id, ws)
|
|
|
|
try:
|
|
await send_state(ws, app)
|
|
|
|
# Announce arrival to everyone already present, then a fresh roster.
|
|
await manager.broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "user_joined",
|
|
"user_id": user_id,
|
|
"username": session.username,
|
|
}
|
|
),
|
|
exclude_user=user_id,
|
|
)
|
|
await manager.broadcast(_roster_frame(app))
|
|
|
|
async for data in ws:
|
|
if data is None:
|
|
break
|
|
|
|
text = str(data)
|
|
# Drop oversized frames before they reach the store/broadcast: this
|
|
# bounds memory and stops a single client from flooding the room.
|
|
if len(text) > MAX_FRAME_SIZE:
|
|
continue
|
|
|
|
app.ctx.session_store.update_activity(user_id)
|
|
|
|
message = Message(
|
|
text=text,
|
|
username=session.username,
|
|
)
|
|
app.ctx.message_store.add(message)
|
|
|
|
await manager.broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "message",
|
|
"data": asdict(message),
|
|
}
|
|
)
|
|
)
|
|
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
await manager.disconnect(user_id)
|
|
# Free the slot + username so the clergy can be rejoined (was previously
|
|
# held until the 1h stale sweep, which also blocked the name).
|
|
app.ctx.session_store.remove(user_id)
|
|
await manager.broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "user_left",
|
|
"user_id": user_id,
|
|
}
|
|
)
|
|
)
|
|
await manager.broadcast(_roster_frame(app))
|
|
|
|
|
|
async def health(request: Request, app: Sanic) -> HTTPResponse:
|
|
return json_response(
|
|
{
|
|
"status": "ok",
|
|
"messages": app.ctx.message_store.count(),
|
|
"users": app.ctx.session_store.count(),
|
|
"timestamp": utcnow().isoformat(),
|
|
}
|
|
)
|
|
|
|
|
|
async def clear_messages(request: Request, app: Sanic) -> HTTPResponse:
|
|
auth_header = request.headers.get("authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return response.json({"error": "Unauthorized"}, status=401)
|
|
|
|
token = auth_header[7:]
|
|
if not hmac.compare_digest(token, app.ctx.admin_token):
|
|
return response.json({"error": "Unauthorized"}, status=401)
|
|
|
|
app.ctx.message_store.clear()
|
|
return json_response({"status": "cleared"})
|