From 99eb93a00269fbfafba7536cf1613b3d4c18c3ce Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 17:49:05 +0200 Subject: fix(hub): require auth and distinct reporters for content reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/reports had no authentication and no rate limit, and counted every raw report row toward AUTO_BLOCK_THRESHOLD regardless of who sent it or from where — two anonymous requests naming any blake3 hash added it to the hub-wide content blocklist. A network-wide censorship and DoS primitive for anyone who learns a public file's hash. - require a signed-in account (get_current_user) - rate-limited (10/hour) - threshold now counts DISTINCT reporting accounts (reporter_id), one vote per account per hash; raised 2 -> 3 - refused outright (403) when the hub has public groups switched off: a private-only hub brokers no public content and nothing syncs the blocklist, so the endpoint would be pure abuse surface - admin blocklist management (/v1/admin/blocklist*) is untouched, so a manual block still works regardless of the public-groups setting Noted while fixing: no node currently consumes ContentBlocklist (swarm_register checks the separate CSAM list), so the network-wide block effect was latent — the abuse surface (DB fill, poisoned moderation signal) was live today. Tests rewritten in test_moderation.py. Third security review, finding H2. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG --- .../meshbay-hub/src/meshbay_hub/api/moderation.py | 90 +++++++++++++++------- 1 file changed, 62 insertions(+), 28 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py index 853f255..0939eec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py @@ -1,13 +1,16 @@ """ MeshBay Hub — moderation endpoints. -Public reporting flow: - POST /v1/reports — report a content hash (no auth required) +Reporting flow: + POST /v1/reports — report a content hash (sign-in required) - Thresholds: - 1st report → logged, node admin notified (future: push notification) - 2nd report → content hash added to blocklist automatically - 3rd+ report → logged as repeat offense (escalation for human review) + Thresholds (counted as DISTINCT reporting accounts, not raw rows): + < AUTO_BLOCK_THRESHOLD distinct reporters → logged + >= AUTO_BLOCK_THRESHOLD distinct reporters → hash added to the blocklist + + The flow only runs while the hub brokers public content: with public groups + switched off instance-wide there is nothing here to serve a reported hash from, + so it is refused rather than left open as an unauthenticated write surface. Admin endpoints: GET /v1/admin/blocklist — list blocked hashes @@ -27,7 +30,9 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user, require_admin +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 ContentBlocklist, ContentReport, User @@ -36,7 +41,11 @@ log = logging.getLogger(__name__) router = APIRouter(tags=["moderation"]) -AUTO_BLOCK_THRESHOLD = 2 # reports before automatic block +# Distinct reporting accounts before a hash is auto-blocked. Kept low for a +# responsive community signal, but note it is only as strong as account +# creation: while a bot can register freely (see the reCAPTCHA gap), the real +# control is the admin reviewing `GET /v1/admin/blocklist` and the audit log. +AUTO_BLOCK_THRESHOLD = 3 # ── Models ──────────────────────────────────────────────────────────────────── @@ -56,34 +65,58 @@ class BlocklistAddRequest(BaseModel): # ── Public endpoints ────────────────────────────────────────────────────────── @router.post("/v1/reports", status_code=201) +@limiter.limit("10/hour") async def report_content( body: ReportRequest, request: Request, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Report a content hash. No authentication required.""" - if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash): - raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)") + """ + Report a public content hash for moderation. - ip = client_ip(request) + Sign-in is required. It used to be anonymous, which made it a censorship + primitive: two unauthenticated POSTs naming any blake3 id auto-added it to + the blocklist that nodes enforce, network-wide, with manual admin removal the + only undo. The threshold now counts *distinct reporting accounts*, one vote + per account per hash. - # Count existing reports for this hash - count_result = await db.execute( - select(func.count()).where(ContentReport.content_hash == body.content_hash)) - count = count_result.scalar_one() + Refused entirely when the hub has public groups switched off: nothing here + brokers public content then, nothing syncs the blocklist, and an open write + endpoint would only be abuse surface. + """ + if not await hub_settings.public_groups_allowed(db): + raise HTTPException( + status_code=403, + detail="This hub does not broker public content, so there is nothing to report here.") - report = ContentReport( - content_hash=body.content_hash, - group_id=body.group_id, - reason=body.reason, - detail=body.detail, - ip_address=ip, - ) - db.add(report) + if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash): + raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)") - action = "logged" - if count + 1 >= AUTO_BLOCK_THRESHOLD: - # Check if already blocked + # One vote per account per hash — a single reporter must not be able to walk + # the threshold up on their own by posting repeatedly. + already = await db.scalar( + select(ContentReport.id).where( + ContentReport.content_hash == body.content_hash, + ContentReport.reporter_id == current_user.id)) + + if not already: + db.add(ContentReport( + content_hash=body.content_hash, + reporter_id=current_user.id, + group_id=body.group_id, + reason=body.reason, + detail=body.detail, + ip_address=client_ip(request), + )) + await db.flush() + + distinct_reporters = await db.scalar( + select(func.count(func.distinct(ContentReport.reporter_id))) + .where(ContentReport.content_hash == body.content_hash)) or 0 + + action = "already_reported" if already else "logged" + if distinct_reporters >= AUTO_BLOCK_THRESHOLD: existing = await db.get(ContentBlocklist, body.content_hash) if not existing: db.add(ContentBlocklist( @@ -92,13 +125,14 @@ async def report_content( added_by="auto", )) action = "auto_blocked" - log.warning("Content auto-blocked after %d reports: %s", count + 1, body.content_hash[:16]) + log.warning("Content auto-blocked after %d distinct reporters: %s", + distinct_reporters, body.content_hash[:16]) await db.commit() return { "status": action, "content_hash": body.content_hash, - "report_count": count + 1, + "report_count": distinct_reporters, "threshold": AUTO_BLOCK_THRESHOLD, } -- cgit v1.2.3