From 17c06bc23252929af13f4361c4a6300ee76a0c51 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 12:14:40 +0200 Subject: docs: AV13–AV15, and two lessons from how they were found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AV13 the mail gate, AV14 the MHP audience and the hub's own identity, AV15 the shape check on the unauthenticated blocklist endpoints. §13.5b also records the auto-block threshold as an open decision rather than a defect. Two lessons, both about tests. A fixture narrower than production tests the fixture — and when it writes down *why* it has to be narrow, that is a bug report nobody filed. Two test helpers described the federation defects accurately, in comments, while routing around them. And a global's state is not a given, in a test least of all. One assertion — that a refusal never logs the address — took three attempts, each passing alone and failing in the full run, for three different reasons: caplog saw nothing because the app configures logging, then a handler saw nothing because an earlier test raised the logger's level, then because `disabled` was left True. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- CLAUDE.md | 23 ++++++++++++++++++++++- docs/MESHBAY_DESIGN.md | 17 +++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e29feb..516228f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ that produced it. | Looking for | Read | |---|---| | What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 | -| What one member can cost the others (`AV1`–`AV12`) | §13.5b — the newest category, and the one the first three reviews had no question for | +| What one member can cost the others (`AV1`–`AV15`) | §13.5b — the newest category, and the one the first three reviews had no question for | | Trust model, and what the project may and may not claim | §2 | | Identity, devices, admission, recovery, the keypair bundle | §3 | | Cryptography, key hierarchy, the group and chat envelopes | §4 | @@ -148,6 +148,27 @@ These are about working on the tree rather than about the design: interface, for the web and the app alike; `packages/meshbay-client/scripts/ sync-ui.js` copies it (`npm run sync-ui`) and CI fails if the copy drifts — **never edit `packages/meshbay-client/ui/` by hand** +- **A test fixture narrower than production tests the fixture — and when it + writes down *why* it has to be narrow, that is a bug report nobody filed.** + `test_federation.py` built its MHP envelopes by hand without an `aud`, and + `test_public_groups_toggle.py` signed its own token with a comment saying + `_issue_mhp_token` "binds `_hub_sk_pem` at import time, before the lifespan + loads it, so it cannot be used from a test", and another saying PyJWT + rejects a token carrying `aud` when decode is given none. Both observations + were exactly right. Between them, MHP could not complete a single + authenticated request between two real hubs, and the hub announced itself as + `meshbay.org` whatever it was configured as — for a month, with the suite + green. When a test has to route around the code to run, the thing it routed + around is the finding. Federation's tokens now come from the real issuer + +- **A global's state is not a given, in a test least of all.** One assertion — + that a refusal never logs the address — took three attempts: `caplog` saw + nothing because the app configures logging, then a handler on the module's + own logger saw nothing because an earlier test had raised its level, then + because `logger.disabled` was left True. Each version passed alone and + failed in the full run. Pin what you depend on (level, `.disabled`, + `logging.disable`) or assert on a value instead of on a side effect + - **A blocking call in an async handler is the whole instance's problem.** `mail._send` is `smtplib` with a ten-second timeout, and it was called straight from four handlers: while the MTA thought about it, nothing else was diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 16e728f..8da4552 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2499,6 +2499,16 @@ the wrong thing** — a per-IP rate limit bounds a caller, never the mailbox tha receives what they cause, which is why `AV10` is a cooldown per *account* under a limit per IP rather than a tighter limit. +**Open for decision, not a defect:** `moderation.AUTO_BLOCK_THRESHOLD` is 3. +Three distinct accounts blocking a hash adds it to the list every node +enforces, network-wide, automatically, with manual admin removal the only +undo. That is already far better than the anonymous version it replaced, and +it is still a censorship primitive an attacker buys for the price of three +email addresses. Raising it buys little; requiring the reporting accounts to +be more than a day old would cost a patient attacker a day and cost an honest +reporter nothing after their first. Left as it is because it is a moderation +policy rather than a bug, and the person who sets that policy is the operator. + `admin.py` was read under this lens and needed nothing. Its moderator/admin line is drawn explicitly — a moderator may not change a role, may not revoke, and may not touch an admin's account at all — self-modification is refused, and @@ -2516,9 +2526,12 @@ had already been asked. | **AV7** | **A node bounds how many peers it holds and how long an unproven one lasts.** The hub's cap is per calling account, which is a limit on each member and not on the machine, so without this an operator's exposure grew with the size of their groups | | **AV8** | **One account cannot make the hub mail another at will.** The invitation email's subject comes from the group row, never from the request, and the endpoint is metered | | **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop` | -| **AV10** | **Every path that makes the hub send mail is metered, per account.** A rate limit that counts by IP bounds a caller, not an inbox. Changing one's address mails an arbitrary stranger, so it carries a cooldown as well as a limit; so does a reset request | +| **AV10** | **Every path that makes the hub send mail is metered, per account.** A rate limit that counts by IP bounds a caller, not an inbox. Changing one's address mails an arbitrary stranger, so it carries a cooldown *and* a daily ceiling; a reset request and a registration resend carry cooldowns | +| **AV13** | **The mail server is not a relay, and `mail.py` is where that is decided.** Every message passes one function; `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError. Under it sit a bound per **recipient** — the thing a person being mail-bombed actually experiences, unmoved by which account, address or endpoint asks — and an instance-wide hourly ceiling, because registration is open and "per account" is a bound an attacker buys more of | | **AV11** | **A namespace a client writes into is closed, and its rows are capped.** The preference key space is an allow-list plus `default_tab:` checked as a group id, the value is length-bounded, and the row count per account is bounded | -| **AV12** | **Every list has an upper bound on `limit` and a floor under `offset`.** Including the ones that take no authentication at all | +| **AV12** | **Every list has an upper bound on `limit` and a floor under `offset`.** Including the ones that take no authentication at all — the public group directory and the content blocklist | +| **AV14** | **MHP binds its audience, and the hub reads its own identity at call time.** A token is minted for one peer and accepted by that peer only. `federation.py` bound `_hub_id` and `_hub_sk_pem` at import, which is before `load_hub_keypair` runs, so it signed with `None` and called itself `meshbay.org` whatever the instance was named — and the verifier named no audience for the `aud` the issuer sets, which PyJWT refuses outright. MHP could not complete one authenticated request between two hubs | +| **AV15** | **A hash is checked for shape before it is a key lookup**, on the unauthenticated blocklist endpoints a node consults | ### 13.6 Chat design findings -- cgit v1.2.3