diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 09:47:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | bf7ff9ec311660318c8562abe03ef4db62475c98 (patch) | |
| tree | 2fd49c42c59e1207574bcf2842b726e4aec82c90 /packages/meshbay-hub/src/meshbay_hub/api/groups.py | |
| parent | 4cce50f09a73739387d5058a6f8183ebac65ae2c (diff) | |
| download | meshbay-bf7ff9ec311660318c8562abe03ef4db62475c98.tar.gz | |
fix: bound what one member can cost the others
An availability review, prompted by the group claim above: a participant
supplies input — who else bears the cost? Six answers where the cost fell on
someone other than the sender, and none of them needs an attacker.
AV3 `chat_notify` carried a `group_id` the hub believed, so any connected
node could write a notification to every member of any group on the
hub, carrying a display string of its choosing, with its account
having no relation to that group. This is the group claim again, two
hundred lines further down the same socket. Gated on what the node is
registered for, and metered: the fan-out is one write per member. The
budget expires by time rather than on disconnect, or reconnecting
would refill it and a node token is good for an hour.
AV4 A swarm source named its own `endpoint` as free text documented as
"ip:port", so an account could publish a third party's address — H6's
`peer_ip` defect, never applied here. Nothing dials a swarm source
today, which is the only reason it was not already a reflection
primitive. It is a transport and a port now, never a host, and the
number of hashes one account may claim is bounded: rows were keyed
(hash, account) with no cap at all.
AV5 `handle_webrtc_answer` resolved any pending `peer_id` from any node's
socket. The answer is the SDP a browser then connects to. That this
had not happened rested on a uuid4 being unguessable.
AV6 `relay_register` had no authentication of any kind: it compared
`pk_relay` against the approved value, which is a *public* key, so
anyone who could read it could rewrite where the hub tells nodes to
send relayed traffic. The module docstring promised signed JWTs and
`jwt` was imported and never used.
AV7 The node held unlimited peer connections and kept one that never
completed a handshake for the life of the daemon. H6 bounded what one
unauthenticated peer costs; the hub's cap is three offers in flight
per *account*, a limit on each caller and not on the machine, so an
operator's exposure grew with the size of their groups.
AV8 `invite-notify` put a request-supplied `group_name` into the subject
of an email the hub sends under its own domain, to any account, with
no rate limit. The name comes from the group row now.
The tests are two accounts each, in one file that says why: a one-member test
proves a one-member property, and every finding here needed a second person
to exist at all. Each was checked against the unfixed code. Two did not
survive that check and were rewritten — one re-enacted the disconnect path
instead of running it (hence `forget_node`), the other called the reaper
itself and would have passed with the call removed from `handle_offer`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 69 |
1 files changed, 66 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 88125c0..b1a22f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -1,5 +1,7 @@ """Group endpoints — /v1/groups/*""" +import re + from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from datetime import datetime, timezone @@ -10,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import hub_settings, mail from meshbay_hub.auth import decrypt_email from meshbay_hub.api.deps import get_current_user, require_user_scope +from meshbay_hub.api.middleware import limiter from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( @@ -192,12 +195,31 @@ async def list_public_groups( class SwarmRegisterRequest(BaseModel): content_hash: str # blake3 hex - endpoint: str # "ip:port" + endpoint: str # "<scheme>:<port>" — a port on the caller, never a host + + +# A transport and a port, and deliberately no host. The field used to be free +# text documented as "ip:port", so a caller could name *someone else's* +# address as a source; nothing dials a swarm source today, which is the only +# reason that was not already a reflection primitive. A reader learns where a +# node is from the node record, which is stamped with the address the announce +# actually came from — so a host here would be a second, weaker, answer to a +# question already settled elsewhere. +_SWARM_ENDPOINT = re.compile(r"^(webrtc|quic):([0-9]{1,5})$") + +# One account, this many public hashes. Rows are keyed (hash, account) with no +# cap, so a loop of invented hashes was unbounded storage growth on a hub +# shared with everyone else. A public library far larger than this is a real +# thing — but it is one a hub operator should be asked about, not something a +# client establishes by writing rows. +MAX_SWARM_HASHES_PER_ACCOUNT = 10_000 @swarm_router.post("/register", status_code=201) +@limiter.limit("120/minute") async def swarm_register( body: SwarmRegisterRequest, + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -209,11 +231,21 @@ async def swarm_register( node's calls 404'd and the leak was masked by a routing bug rather than prevented. Nodes now filter by group visibility before calling, and the path is correct, so the filter has to be right. + + Availability: the endpoint is a port, not an address, and the number of + hashes one account may claim is bounded. See the two constants above. """ from meshbay_hub.csam import check_content_hash if check_content_hash(body.content_hash): raise HTTPException(status_code=451, detail="Content blocked") + m = _SWARM_ENDPOINT.match(body.endpoint or "") + if not m or not (0 < int(m.group(2)) < 65536): + raise HTTPException( + status_code=422, + detail="endpoint must be '<webrtc|quic>:<port>' — a port on the " + "registering node, not an address") + from datetime import datetime, timezone existing = await db.get(SwarmSource, (body.content_hash, current_user.id)) now = datetime.now(timezone.utc) @@ -221,6 +253,14 @@ async def swarm_register( existing.endpoint = body.endpoint existing.last_seen = now else: + held = (await db.execute( + select(func.count()).select_from(SwarmSource) + .where(SwarmSource.node_id == current_user.id))).scalar() or 0 + if held >= MAX_SWARM_HASHES_PER_ACCOUNT: + raise HTTPException( + status_code=429, + detail="This account already claims the maximum number of " + "public content hashes") db.add(SwarmSource( content_hash=body.content_hash, node_id=current_user.id, @@ -672,16 +712,27 @@ async def delete_group( return {"status": "deleted", "group_id": group_id} +# `XXXX-XXXX`, as `roster.generate_code` produces. Checked because this string +# is placed in an email the hub sends under its own domain, and the endpoint +# used to accept any text at all. +_INVITE_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") + + class InviteNotifyRequest(BaseModel): username: str code: str - group_name: str + # `group_name` used to be here and went straight into the email's subject + # line. The hub knows the group's name — it is reading the row two lines + # into the handler — so accepting a second answer only let the sender + # choose the subject of a message the hub signs with its own domain. @router.post("/{group_id}/invite-notify") +@limiter.limit("20/hour") async def invite_notify( group_id: str, body: InviteNotifyRequest, + request: Request, current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): @@ -691,6 +742,15 @@ async def invite_notify( because the inviter's browser sends it here. The hub looks up the invitee's encrypted email, decrypts it, and sends the notification. The inviter never sees the email address. + + Availability: the target is any account on the hub — it has to be, since + an invitee is by definition not yet a member — so this is the one endpoint + where one user causes mail to be sent to another. It was unmetered and the + subject line came from the request. Anyone who created a group, which is to + say anyone, could send any registered account arbitrary text from the hub's + own domain, as fast as they liked. The rate limit and the two checks below + are what keep that from being a phishing kit with the hub's reputation + attached. """ group = await db.get(Group, group_id) if not group: @@ -699,6 +759,9 @@ async def invite_notify( raise HTTPException(status_code=403, detail="Only the group owner can send invitations") + if not _INVITE_CODE.match(body.code or ""): + raise HTTPException(status_code=422, detail="Not an invite code") + target = (await db.execute( select(User).where(User.username == body.username))).scalar_one_or_none() if not target: @@ -715,7 +778,7 @@ async def invite_notify( try: mail.send_invite_notification( - email, body.code, current_user.username, body.group_name) + email, body.code, current_user.username, group.name) except Exception: return {"status": "send_failed"} |