diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 81 |
1 files changed, 81 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 59198d5..bc78644 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -183,6 +183,28 @@ _LINK_PREVIEW_RATE_WINDOW = 60.0 _LINK_PREVIEW_RATE_PER_CONN = 15 _LINK_PREVIEW_RATE_NODE = 60 +# Chat limits. A message is a member-supplied write onto the operator's disk +# (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there +# to every other connected member and turned into a notification for every member +# of the group. Nothing bounded any of it: the only ceiling was the frame size, +# 64 MB once the handshake is done, so one member in a loop could fill the +# operator's disk and saturate everyone else's connection. Uploads — the other +# member-supplied write — have carried four protections and a size cap since +# C5a; this is the same question asked of the path nobody had asked it of. +# +# 64 KB of ciphertext is about sixty thousand characters. The sealed payload is +# the text, a thread id, a display name and a timestamp: an attachment is a file +# on a root and travels as a reference (§4.5), so nothing legitimate comes close. +MAX_CHAT_CIPHERTEXT = 64 * 1024 +# Per account per group, not per connection: a second tab does not make a person +# type faster, and keying on the session would hand a script one budget per +# socket. Sixty a minute is far above a human and far below a flood. +_CHAT_RATE_WINDOW = 60.0 +_CHAT_RATE_PER_ACCOUNT = 60 +# When the map of senders grows past this, the stale entries are dropped. A node +# with more live chatters than this in one window is not the case being bounded. +_CHAT_RATE_MAX_TRACKED = 1000 + # Upload limits (finding C5a). Uploads used to land directly in the shared root under # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its @@ -4431,6 +4453,15 @@ class WebRTCPeerSession: self._audit("chat_refused", refusal) return + # Separate from the envelope check above, and deliberately: that one asks + # whether the message is well formed and authentic, this one asks what it + # costs everyone else. Before either is written to disk or relayed. + detail, code = self._chat_bounds_refusal(raw) + if detail: + self._send({"type": "error", "detail": detail, "code": code}) + self._audit("chat_refused", code) + return + if sender_name: self._user_names()[self._user_id] = sender_name if chat_store: @@ -4538,6 +4569,56 @@ class WebRTCPeerSession: return "" + def _chat_bounds_refusal(self, ct: bytes) -> tuple[str, str]: + """What this message would cost the others, or ("", "") to accept it. + + Two bounds, and each answers a different half of "who pays". A message + is written to `chat.db` on the operator's disk and kept — retention is a + manual command (§6.6) — then relayed to every other connected member and + turned into a notification for every member of the group. So **size** + bounds what one message costs, and **rate** bounds how often one member + may impose it. + + There is deliberately no node-wide ceiling to go with the per-account + one. The link-preview limiter has both because a preview spends the + *node's* egress and its third-party quota, which is one shared thing; a + chat message spends the sender's own group. A node-wide chat ceiling + would let a busy group silence a quiet one, which is the same class of + defect this bound exists to close, one level up. + """ + if len(ct) > MAX_CHAT_CIPHERTEXT: + return ("This message is too large to send in chat — " + "send a large file as an attachment instead.", + "chat_too_large") + if not self._chat_rate_ok(): + return ("Too many messages just now — wait a moment.", + "chat_rate_limited") + return ("", "") + + def _chat_rate_ok(self) -> bool: + """True when this sender is within their window; records it when so. + + Keyed by (group, account) on the transport context rather than on the + session: the sender is authenticated, so this is the one identifier a + second tab — or fifty of them — cannot multiply. The window is trimmed + on every call, and the map of senders is swept when it grows, so neither + can be the memory leak the bound was added to prevent. + """ + now = time.monotonic() + hits: dict = self._ctx.setdefault("chat_hits", {}) + if len(hits) > _CHAT_RATE_MAX_TRACKED: + for key, times in list(hits.items()): + if not times or now - times[-1] >= _CHAT_RATE_WINDOW: + hits.pop(key, None) + key = (self._group_id or "", self._user_id or "") + mine = [t for t in hits.get(key, ()) if now - t < _CHAT_RATE_WINDOW] + if len(mine) >= _CHAT_RATE_PER_ACCOUNT: + hits[key] = mine + return False + mine.append(now) + hits[key] = mine + return True + async def _store_chat_message(self, chat_store, **kwargs) -> None: """ Persist one message, treating a replay as already-done. |