harden(ft,auth,net): cap transfers/frames, evict stale SRP, distrust XFF
CI / rust client (hh) (macos-latest) (push) Has been cancelled
CI / rust client (hh) (ubuntu-latest) (push) Has been cancelled
CI / rust coverage (push) Has been cancelled
CI / python server (3.10) (push) Has been cancelled
CI / python server (3.11) (push) Has been cancelled
CI / python server (3.12) (push) Has been cancelled
CI / headless e2e smoke (push) Has been cancelled
CI / dependency audit (push) Has been cancelled
CI / secret scanning (push) Has been cancelled

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>
This commit is contained in:
leetcrypt
2026-06-05 06:59:16 -07:00
parent 5676216a2f
commit 2c4a4f9a22
5 changed files with 74 additions and 6 deletions
+10 -2
View File
@@ -1,3 +1,4 @@
import os
import time
from collections import defaultdict
from datetime import datetime, timezone
@@ -10,9 +11,16 @@ 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 forwarded := request.headers.get("x-forwarded-for"):
return forwarded.split(",")[0].strip()
if _TRUST_PROXY:
if forwarded := request.headers.get("x-forwarded-for"):
return forwarded.split(",")[0].strip()
return request.ip
+18
View File
@@ -1,3 +1,4 @@
import time
from dataclasses import dataclass, field
from typing import Optional
from uuid import uuid4
@@ -7,6 +8,11 @@ import srp
srp.rfc5054_enable()
# Half-finished handshakes (init without a matching verify) would otherwise pile
# up forever, letting an attacker exhaust memory. Evict any session that hasn't
# authenticated within this many seconds whenever a new handshake begins.
UNVERIFIED_TTL_SECONDS = 60
@dataclass
class SRPSession:
@@ -15,6 +21,7 @@ class SRPSession:
svr: Optional[srp.Verifier] = None
session_key: Optional[bytes] = None
authenticated: bool = False
created_at: float = field(default_factory=time.monotonic)
class SRPAuthManager:
@@ -25,9 +32,20 @@ class SRPAuthManager:
b"chat", self.password, hash_alg=srp.SHA256
)
def _evict_stale_unverified(self) -> None:
now = time.monotonic()
stale = [
uid
for uid, s in self.sessions.items()
if not s.authenticated and now - s.created_at > UNVERIFIED_TTL_SECONDS
]
for uid in stale:
del self.sessions[uid]
def init_auth(
self, username: str, client_public: bytes
) -> tuple[str, bytes, bytes]:
self._evict_stale_unverified()
session = SRPSession(username=username)
svr = srp.Verifier(
+14 -1
View File
@@ -11,6 +11,13 @@ 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()
@@ -152,10 +159,16 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
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=str(data),
text=text,
username=session.username,
)
app.ctx.message_store.add(message)