From 69554fac7eba6eef7eb8a1c0111c5b92e7f21256 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 11:22:24 +0200 Subject: 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 --- packages/meshbay-node/tests/test_linkpreview.py | 80 ++++++++++++++++++++++ packages/meshbay-node/tests/test_roster_pairing.py | 74 ++++++++++++++++++++ 2 files changed, 154 insertions(+) (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/test_linkpreview.py b/packages/meshbay-node/tests/test_linkpreview.py index a14b173..9fca186 100644 --- a/packages/meshbay-node/tests/test_linkpreview.py +++ b/packages/meshbay-node/tests/test_linkpreview.py @@ -193,3 +193,83 @@ async def test_fetch_image_refuses_a_decompression_bomb(resolves_public, monkeyp return httpx.Response(200, headers={"content-type": "image/png"}, content=bomb) async with _client(handler) as c: assert await linkpreview.fetch_image("https://example.com/x.png", client=c) is None + + +# ── Size caps bind while reading, not after ───────────────────────────────── +# +# `client.get` read and decoded the whole body before the caps looked at it, so +# a link posted in chat could make the node hold any amount of data. These +# count what the server actually had to hand over. + +_CHUNK = 64 * 1024 + + +def _endless(counter, head=b""): + async def body(): + if head: + counter["sent"] += len(head) + yield head + for _ in range(2000): # 125 MiB if nobody stops + counter["sent"] += _CHUNK + yield b"x" * _CHUNK + return body() + + +async def test_a_huge_page_is_not_read_past_the_cap(resolves_public): + counter = {"sent": 0} + head = b"T" + + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html"}, + content=_endless(counter, head)) + async with _client(handler) as c: + meta = await linkpreview.fetch_preview("https://example.com/", client=c) + assert meta is not None and meta["title"] == "T" + assert counter["sent"] <= linkpreview._MAX_HTML_BYTES + 2 * _CHUNK + + +async def test_a_huge_image_is_refused_without_being_read(resolves_public): + counter = {"sent": 0} + + def handler(request): + return httpx.Response(200, headers={"content-type": "image/png"}, + content=_endless(counter)) + async with _client(handler) as c: + assert await linkpreview.fetch_image("https://example.com/x.png", client=c) is None + assert counter["sent"] <= linkpreview._MAX_IMAGE_BYTES + 2 * _CHUNK + + +async def test_a_compressed_body_is_capped_after_decoding(resolves_public): + import zlib + comp = zlib.compressobj(9, zlib.DEFLATED, 31) # gzip + counter = {"inflated": 0} + + async def body(): + yield comp.compress(b"T") + for _ in range(2000): # 125 MiB inflated + counter["inflated"] += _CHUNK + # Flushed per chunk, so what is counted is what went on the wire. + yield comp.compress(b"x" * _CHUNK) + comp.flush(zlib.Z_SYNC_FLUSH) + yield comp.flush() + + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html", + "content-encoding": "gzip"}, + content=body()) + async with _client(handler) as c: + meta = await linkpreview.fetch_preview("https://example.com/", client=c) + assert meta is not None + assert counter["inflated"] < 50 * _CHUNK # stopped early, not at 125 MiB + + +async def test_a_declared_oversized_image_is_not_read(resolves_public): + counter = {"sent": 0} + + def handler(request): + return httpx.Response( + 200, headers={"content-type": "image/png", + "content-length": str(linkpreview._MAX_IMAGE_BYTES + 1)}, + content=_endless(counter)) + async with _client(handler) as c: + assert await linkpreview.fetch_image("https://example.com/x.png", client=c) is None + assert counter["sent"] <= _CHUNK diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 5c94855..585a909 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -339,6 +339,80 @@ async def test_failures_are_counted_across_connections(tmp_path, roster): "reconnecting must not reset the pairing budget") +async def _grind_wrong_codes(tmp_path, roster, ctx, accounts, per_account): + """Wrong codes from several accounts, each on connections of its own.""" + for n in range(accounts): + uid = f"guesser{n}" + for _ in range(per_account): + session = _session(tmp_path, roster, user_id=uid) + session._ctx = ctx + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, + code="AAAA-AAAA", user_id=uid)) + + +async def test_a_full_code_lock_never_refuses_a_known_member(tmp_path, roster): + """ + Every member reconnecting gets the group key through join_request, so a + lock on wrong codes that also refused *recognised* devices let one member + take the node away from everybody. + """ + GROUP = "b" * 32 + gek = generate_gek() + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP, "grenet", ROLE_MEMBER, "active", "cbesson") + + member = _session(tmp_path, roster, user_id="grenet", group_id=GROUP, + gek=gek, join_policy="invite") + await _grind_wrong_codes(tmp_path, roster, member._ctx, accounts=6, + per_account=5) + assert len(member._ctx["join_failures"]) >= 20 + + await member._do_join_request( + _join_msg(member, sk_ed, pk_ed_b64, pk_x_b64, user_id="grenet")) + reply = _last(member) + assert reply.get("ok") is True and reply.get("gek") is True, reply + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek + + +async def test_ordinary_refusals_do_not_feed_the_code_lock(tmp_path, roster): + """`code_required` is what every first contact without a code hears.""" + ctx = None + for n in range(30): + session = _session(tmp_path, roster, user_id=f"newcomer{n}") + if ctx is None: + ctx = session._ctx + session._ctx = ctx + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id=f"newcomer{n}")) + assert _last(session).get("reason") == "code_required" + assert not ctx.get("join_failures") + + +async def test_one_account_guessing_locks_itself_not_the_others(tmp_path, roster): + session = _session(tmp_path, roster) + ctx = session._ctx + await _grind_wrong_codes(tmp_path, roster, ctx, accounts=1, per_account=6) + guesser = _session(tmp_path, roster, user_id="guesser0") + guesser._ctx = ctx + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await guesser._do_join_request( + _join_msg(guesser, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA", + user_id="guesser0")) + assert _last(guesser).get("detail") == "Pairing temporarily locked" + + code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli") + honest = _session(tmp_path, roster, user_id="grenet") + honest._ctx = ctx + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await honest._do_join_request( + _join_msg(honest, sk_ed, pk_ed_b64, pk_x_b64, code=code)) + assert await roster.find_device("grenet", pk_ed_b64) is not None + + async def test_group_id_cannot_name_another_group(tmp_path, roster): session = _session(tmp_path, roster) session._group_id = "a" * 32 -- cgit v1.2.3