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>
57 lines
1.7 KiB
Python
57 lines
1.7 KiB
Python
import os
|
|
import time
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from dataclasses import asdict
|
|
import json
|
|
from sanic import Request, Sanic, Websocket
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# Only honor X-Forwarded-For when explicitly told we sit behind a trusted proxy
|
|
# (TRUST_PROXY=1). Otherwise a direct client can spoof the header to forge a
|
|
# source IP and dodge the per-IP rate limiter, so we use the real peer address.
|
|
_TRUST_PROXY = os.environ.get("TRUST_PROXY", "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def get_client_ip(request: Request) -> str:
|
|
if _TRUST_PROXY:
|
|
if forwarded := request.headers.get("x-forwarded-for"):
|
|
return forwarded.split(",")[0].strip()
|
|
return request.ip
|
|
|
|
|
|
async def send_state(ws: Websocket, app: Sanic) -> None:
|
|
messages = app.ctx.message_store.get_all()
|
|
users = app.ctx.session_store.get_all()
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"type": "init",
|
|
"messages": [asdict(m) for m in messages],
|
|
"users": [
|
|
{"user_id": u.user_id, "username": u.username} for u in users
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
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
|