aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py69
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"}