diff options
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"} |