diff options
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/mail.py | 28 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_mail_is_not_a_relay.py | 50 | ||||
| -rw-r--r-- | packaging/conf/hub.toml.example | 5 |
4 files changed, 71 insertions, 14 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index a9653b6..673ad05 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3095,7 +3095,7 @@ had already been asked. | **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`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter. **ffmpeg's own output too**, through `asyncio.to_thread` rather than that per-root thread: a temp file is not a group root and has no platter to serialise against, but a whole transcode read inline is still tens of megabytes of blocking read | | **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 | +| **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. The recipient's daily cap counts every purpose; its **cooldown is per family**, invitations on one side and the account's own steps on the other, because one cooldown for both refused — silently — the sign-up code of everyone who registered within two minutes of receiving an invitation link | | **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:<group_id>` 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 — the public group directory and the content blocklist | | **AV16** | **A bound the operator can see and change, and that a restart does not forget.** The mail allowance lives in `mail_quota`, not in a module dict — a deploy used to hand out a fresh budget, and the hub is deployed often. The values are settings with defaults in `hub.toml` and a block in the admin panel, because the hour a budget runs out is not when anyone wants to edit a file and restart; `/v1/admin/mail` says how much of the hour is left, which was previously visible only as an absence of mail | diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index d2659e0..d36a829 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -57,6 +57,21 @@ ALLOWED_PURPOSES = frozenset({ # sign-ups cannot lock out someone trying to recover their passphrase. RECOVERY_PURPOSES = frozenset({"password_reset", "invite"}) +# Which messages share a recipient's cooldown. Within a family the cooldown is +# shared, so a burst cannot walk around it by switching purpose. Across the two +# it is not, because they are two halves of one conversation: an invitation +# arrives, the invitee registers a minute later, and the code they asked for +# was refused — silently — by the cooldown the invitation had just armed. An +# invitation link made that the normal case rather than a rare one. The daily +# cap per recipient still counts every message, whichever family. +# Someone else writes to you about a group; everything else is a step of your +# own account's (a sign-up code, a reset, an address change). +_INVITATIONS = frozenset({"invite", "invite_link"}) + + +def _cooldown_family(purpose: str) -> str: + return "invitation" if purpose in _INVITATIONS else "account" + def destination_key(address: str) -> str: """A stable handle for one recipient that is not the address itself. @@ -204,13 +219,16 @@ async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None: EXHAUSTED_ALL if purpose in RECOVERY_PURPOSES else EXHAUSTED_GENERAL) raise - # Then the recipient: across every purpose, account and endpoint. This is - # what a person being mail-bombed actually experiences, and the only bound - # that describes it. + # Then the recipient. The cooldown per family (above) first, so a message + # refused for coming too soon does not also spend the day's allowance; then + # the daily cap, across every purpose, account and endpoint — what a person + # being mail-bombed actually experiences, and the only bound that says so. + dest = destination_key(address) await _take( - db, destination_key(address), timedelta(days=1), - limits["destination_daily_cap"], + db, f"cool:{_cooldown_family(purpose)}:{dest[len('dest:'):]}", + timedelta(days=1), 10**9, timedelta(seconds=limits["destination_cooldown_seconds"])) + await _take(db, dest, timedelta(days=1), limits["destination_daily_cap"], None) async def status(db) -> dict: diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index a3f9a21..157dbd5 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -148,21 +148,57 @@ async def test_one_recipient_has_a_cooldown_and_a_daily_allowance(db_session): "a second purpose walked around the cooldown") cap = MailConfig().destination_daily_cap - key = mail_mod.destination_key(victim) + cool = "cool:invitation:" + mail_mod.destination_key(victim)[len("dest:"):] + + async def past_the_cooldown(): + # Step past it without waiting it out. The daily count is deliberately + # left alone. + row = await db_session.get(MailQuota, cool) + if row: + row.last_sent = None + await db_session.commit() + for _ in range(cap - 1): - # Step past the cooldown without waiting it out. The daily count is - # deliberately left alone. - (await db_session.get(MailQuota, key)).last_sent = None - await db_session.commit() + await past_the_cooldown() assert await _charge(db_session, "invite", victim) - (await db_session.get(MailQuota, key)).last_sent = None - await db_session.commit() + await past_the_cooldown() assert not await _charge(db_session, "invite", victim), ( f"the {cap}-a-day allowance for one recipient was not enforced") @pytest.mark.asyncio +async def test_an_invitation_does_not_stop_the_code_its_invitee_asks_for(db_session): + """ + The regression: an invitation link arrives, the invitee registers a minute + later, and the verification code was refused — silently — by the cooldown + the invitation had just armed. Nobody invited by link could sign up without + waiting two minutes and then asking again, with nothing on screen saying so. + + The two are one conversation, so they do not share a cooldown; each family + still keeps its own, and the daily cap still counts both. + """ + async def charge(purpose, addr): + try: + await mail_mod.reserve(db_session, purpose, addr, sender="owner-1") + except mail_mod.MailRefused: + await db_session.rollback() + return False + await db_session.commit() + return True + + for invitation in ("invite", "invite_link"): + addr = f"newcomer-{invitation}@example.test" + assert await charge(invitation, addr) + assert await charge("registration", addr), ( + f"a verification code was refused after an {invitation} mail") + assert not await charge("password_reset", addr), ( + "within the account family the cooldown must still hold") + assert not await charge("invite", addr), ( + "a second invitation in the same burst must still wait") + + +@pytest.mark.asyncio async def test_the_same_recipient_is_one_recipient_however_it_is_written(db_session): """Case and surrounding space are not a new person.""" assert await _charge(db_session, "registration", "Victim@Example.Test") diff --git a/packaging/conf/hub.toml.example b/packaging/conf/hub.toml.example index fc05591..640b2ce 100644 --- a/packaging/conf/hub.toml.example +++ b/packaging/conf/hub.toml.example @@ -107,7 +107,10 @@ hourly_budget = 200 # `hourly_budget - hourly_reserved_for_recovery`. hourly_reserved_for_recovery = 50 -# Per recipient, across every purpose, account and endpoint. This is the bound +# Per recipient. The daily cap counts every purpose, account and endpoint; the +# cooldown is shared within two families — invitations, and an account's own +# steps (sign-up code, reset, address change) — so an invitation does not hold +# back the sign-up code its invitee asks for a minute later. This is the bound # that describes what a person being flooded actually receives, and the only # one that does. destination_daily_cap = 10 |