aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:22:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:22:24 +0200
commit69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (patch)
tree87ae908d008159c4237b31dade3f385a629c3c7b /packages/meshbay-node/src/meshbay_node/transport/webrtc
parente2a487c3cd24589b9d9bd298b79d8efaa9914480 (diff)
downloadmeshbay-69554fac7eba6eef7eb8a1c0111c5b92e7f21256.tar.gz
fix: a member can no longer lock a node, crash it with a link, or stop hub cleanup
- node: only a wrong code counts towards the join lock, now per account (5) as well as node-wide (20), and it is consulted only when a code is tried. Every member reconnecting gets the group key through join_request, so a lock checked before recognition let one member refuse it to everyone. - node: link previews read the body as a stream and stop at the cap, counted on decoded bytes; a declared oversized image is not read; 15 s total deadline; image decoding off the loop. `client.get` had buffered the whole (decompressed) response before the caps looked at it. - hub: the daily purge of never-verified accounts detaches their IP-log rows (keeping the name) and clears every other reference first, and each cleanup step runs on its own. On PostgreSQL the bare DELETE violated the ip_logs foreign key and stopped every purge behind it for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py74
1 files changed, 55 insertions, 19 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
index e678a13..f94cade 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
@@ -40,9 +40,18 @@ _INVITE_ID_RE = re.compile(r"[0-9a-f]{32}")
MAX_JOIN_ATTEMPTS = 5
# Per-connection limits alone would not bind an attacker who can open connections
# at will — and the adversary who can mint tokens for any account is the hub. So
-# failed pairings are also counted node-wide over a window.
+# wrong codes are also counted per account and node-wide over a window.
+#
+# Only a *wrong code* counts, and the lock is consulted only when a code is about
+# to be tried. Every member reconnecting obtains the group key through this very
+# message — no per-member bundle is stored — so a lock checked at the top of it,
+# fed by ordinary refusals such as `code_required`, let any one member refuse the
+# key to everybody on the node for as long as they kept failing. A device the
+# node already pinned, joining without a code, is never subject to it.
MAX_JOIN_FAILURES_WINDOW = 20
+MAX_JOIN_FAILURES_PER_ACCOUNT = 5
JOIN_FAILURE_WINDOW = 600 # seconds
+_JOIN_FAILURE_ACCOUNTS_TRACKED = 1000
class AdmissionMixin:
@@ -124,15 +133,11 @@ class AdmissionMixin:
# ── Pairing and join (H3, M3) ────────────────────────────────────────────
- def _join_refuse(self, reason: str, audit_detail: str = "") -> None:
+ def _join_refuse(self, reason: str, audit_detail: str = "", *,
+ wrong_code: bool = False) -> None:
self._join_attempts += 1
- # Node-wide window, shared across connections: reconnecting must not reset
- # the budget.
- now = time.time()
- failures = [t for t in self._ctx.get("join_failures", [])
- if now - t < JOIN_FAILURE_WINDOW]
- failures.append(now)
- self._ctx["join_failures"] = failures
+ if wrong_code:
+ self._record_wrong_code()
self._audit_join("join_refused", audit_detail or reason)
self._send({
"type": MNP.JOIN_RESULT,
@@ -141,6 +146,41 @@ class AdmissionMixin:
"reason": reason,
})
+ def _record_wrong_code(self) -> None:
+ """Count one wrong code, per account and node-wide, across connections:
+ reconnecting must not reset either budget."""
+ now = time.time()
+ failures = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ failures.append(now)
+ self._ctx["join_failures"] = failures
+
+ per_account: dict = self._ctx.setdefault("join_failures_by_account", {})
+ if len(per_account) > _JOIN_FAILURE_ACCOUNTS_TRACKED:
+ for uid, times in list(per_account.items()):
+ if not times or now - times[-1] >= JOIN_FAILURE_WINDOW:
+ per_account.pop(uid, None)
+ uid = self._user_id or getattr(self, "_pending_sub", "")
+ mine = [t for t in per_account.get(uid, ()) if now - t < JOIN_FAILURE_WINDOW]
+ mine.append(now)
+ per_account[uid] = mine
+
+ def _code_guessing_locked(self, user_id: str) -> bool:
+ """Whether a code may be tried now. Refuses, and says so, when not."""
+ now = time.time()
+ recent = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ mine = [t for t in (self._ctx.get("join_failures_by_account") or {})
+ .get(user_id, ()) if now - t < JOIN_FAILURE_WINDOW]
+ if len(mine) >= MAX_JOIN_FAILURES_PER_ACCOUNT:
+ self._audit_join("join_throttled", f"{len(mine)} wrong codes by this account")
+ elif len(recent) >= MAX_JOIN_FAILURES_WINDOW:
+ self._audit_join("join_throttled", f"{len(recent)} wrong codes on this node")
+ else:
+ return False
+ self._send({"type": "error", "detail": "Pairing temporarily locked"})
+ return True
+
def _audit_join(self, event: str, detail: str) -> None:
audit = self._ctx.get("audit_store")
if not audit:
@@ -177,14 +217,6 @@ class AdmissionMixin:
self._send({"type": "error", "detail": "Too many attempts"})
return
- now = time.time()
- recent = [t for t in self._ctx.get("join_failures", [])
- if now - t < JOIN_FAILURE_WINDOW]
- if len(recent) >= MAX_JOIN_FAILURES_WINDOW:
- self._audit_join("join_throttled", f"{len(recent)} failures in window")
- self._send({"type": "error", "detail": "Pairing temporarily locked"})
- return
-
user_id = self._user_id or getattr(self, "_pending_sub", "")
username = self._username or getattr(self, "_pending_username", "")
if not user_id:
@@ -295,9 +327,11 @@ class AdmissionMixin:
if not code:
self._join_refuse("code_required")
return
+ if self._code_guessing_locked(user_id):
+ return
invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
- self._join_refuse("code_invalid")
+ self._join_refuse("code_invalid", wrong_code=True)
return
await roster.set_member(
group_id=invite["group_id"], user_id=user_id,
@@ -337,9 +371,11 @@ class AdmissionMixin:
self._join_refuse("code_required")
return
+ if self._code_guessing_locked(user_id):
+ return
invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
- self._join_refuse("code_invalid")
+ self._join_refuse("code_invalid", wrong_code=True)
return
await self._pin_and_admit(