diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:22:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:22:24 +0200 |
| commit | 69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (patch) | |
| tree | 87ae908d008159c4237b31dade3f385a629c3c7b /packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py | |
| parent | e2a487c3cd24589b9d9bd298b79d8efaa9914480 (diff) | |
| download | meshbay-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/admission.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py | 74 |
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( |