aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 10:08:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit02f061ee2c1824734bf63c91d39b47848926f59c (patch)
tree8c3261860876b73fa2eec82a4b648ded2873261b /packages/meshbay-hub/src/meshbay_hub/api/groups.py
parent7c3a1d6fd765ef0421d0f3d85e512e10ea2f6f87 (diff)
downloadmeshbay-02f061ee2c1824734bf63c91d39b47848926f59c.tar.gz
fix(hub): no mail from the event loop, and a ceiling on every path that sends it
`users.py` and `admin.py` under the availability lens. `admin.py` needed nothing — its moderator/admin line is drawn explicitly, self-modification is refused, and every list it serves is bounded. `users.py` had four findings and one of them is the worst of this whole pass. AV9 `mail._send` is `smtplib` with a ten-second timeout, called straight from four async handlers. That wait is not one request's, it is the instance's: nothing else served, no node socket read, no WebRTC offer relayed, until the MTA answers. Reachable by any signed-in user at request rate through the endpoint below. It has no symptom a test catches — everything simply works slowly, for everyone, whenever the mail server is having a bad day. AV10 `PATCH /v1/users/me` is the third path that makes the hub send mail and the only one with neither a rate limit nor a captcha, while `register` and `password/reset-request` have both. The address is any string the caller types and the duplicate check only rejects one already held by an account here, so every address *not* registered on this hub was a valid target: a relay for verification codes with the hub's own reputation attached. A rate limit counting by IP bounds a caller and not an inbox, so the floor under it is a cooldown per account — the same for a reset request, whose cost also lands in a mailbox that is not the asker's. AV11 `default_tab:` accepted any suffix on a `{key:path}` route with an unbounded Text value and no cap on rows: one account could write without limit into a table shared with everyone. The suffix is a group id, which is what the SPA writes, so it is checked as one. A key over 64 characters was also a 500 rather than a 400 — the column is String(64), which PostgreSQL enforces and SQLite does not, so it would have appeared in production and in no test. AV12 `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and no floor under `offset`, while every list in `admin.py` carries `le=200`. The group directory takes no authentication at all. Two shapes recur and are now named in §13.5b: a limit written on one of several equivalent paths, and a bound that counts the wrong thing. 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.py14
1 files changed, 9 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index b1a22f7..c44888a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -2,7 +2,7 @@
import re
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
@@ -140,9 +140,12 @@ async def group_online_nodes(
@router.get("")
async def list_public_groups(
db: AsyncSession = Depends(get_db),
- q: str = "",
- limit: int = 50,
- offset: int = 0,
+ q: str = Query(default="", max_length=200),
+ # This one takes no authentication at all, and had no upper bound: any
+ # stranger could ask the hub for the entire public directory in a single
+ # query, repeatedly. Bounded like every list in admin.py.
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
include_federated: bool = True,
):
"""List/search public groups — local and optionally federated. No auth required."""
@@ -777,7 +780,8 @@ async def invite_notify(
return {"status": "no_email"}
try:
- mail.send_invite_notification(
+ await mail.send_off_loop(
+ mail.send_invite_notification,
email, body.code, current_user.username, group.name)
except Exception:
return {"status": "send_failed"}