summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-13 20:28:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-13 20:28:52 +0200
commit729d28030746bb8b3688f85833c228acd304be61 (patch)
tree6145f5af95497bc232a8b234b29e55ca21994a71 /packages/meshbay-node/src/meshbay_node/transport
parent9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85 (diff)
downloadmeshbay-729d28030746bb8b3688f85833c228acd304be61.tar.gz
fix(node): chat is bounded in size and in rate
A chat message is the plainest member-supplied write there is: the node stores it in `chat.db`, where nothing expires it — retention is a manual command (§6.6) — relays it to every other connected member, and has the hub write a notification for every member of the group. Nothing bounded any of it. The only ceiling was the DataChannel frame, 64 MB once the handshake is done, so one member in a loop filled the operator's disk and saturated everyone else's connection, and the node's answer to each message was `ack`. Uploads, the other member-supplied write, have carried a filename allowlist, strict chunk ordering, a no-overwrite rule and a 4 GB cap since C5a — because somebody asked what one member costs the others on that path. Nobody had asked it on this one. Two bounds, for the two halves of the question: **64 KB of ciphertext** for what one message may cost, and **60 a minute per account per group** for how often one member may impose it. Both are checked before anything is stored or relayed; a refusal names itself and is audited, so "why is my disk full" has an answer. The rate is keyed by account, not by connection: a second tab does not make anyone type faster, and keying on the session would hand a script one budget per socket it opens. No node-wide ceiling beside it, deliberately. The link-preview limiter has one 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, and a node-wide ceiling would let a busy group silence a quiet one — this same defect one level up. The last test in the new file is that property: a member at their limit has not spent anybody else's. Two things stay open on purpose and are named rather than quietly done: retention still keeps everything, because a default that deletes people's history is not a review's call; and the composer still offers to send an oversized message, so this is §6.4's pattern with only the node half built. §6.6 gains the rule, §13.5b the label — AV20, with AV21–AV23 registering the three fixes this week that closed the same kind of gap elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py81
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.