summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 17:49:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 17:49:05 +0200
commit99eb93a00269fbfafba7536cf1613b3d4c18c3ce (patch)
tree20c51e5d4907f2d621edeb31b9a2a46bbac2ab33 /packages/meshbay-hub/src
parent9f95bf2deaec0c31e5fac95e1068db40b878e217 (diff)
downloadmeshbay-99eb93a00269fbfafba7536cf1613b3d4c18c3ce.tar.gz
fix(hub): require auth and distinct reporters for content reports
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py86
1 files changed, 60 insertions, 26 deletions
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."""
+ """
+ Report a public content hash for moderation.
+
+ 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.
+
+ 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.")
+
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)")
- ip = client_ip(request)
+ # 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))
- # 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()
+ 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()
- report = ContentReport(
- content_hash=body.content_hash,
- group_id=body.group_id,
- reason=body.reason,
- detail=body.detail,
- ip_address=ip,
- )
- db.add(report)
+ distinct_reporters = await db.scalar(
+ select(func.count(func.distinct(ContentReport.reporter_id)))
+ .where(ContentReport.content_hash == body.content_hash)) or 0
- action = "logged"
- if count + 1 >= AUTO_BLOCK_THRESHOLD:
- # Check if already blocked
+ 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,
}