summaryrefslogtreecommitdiffstats
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
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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py86
-rw-r--r--packages/meshbay-hub/tests/test_moderation.py132
2 files changed, 147 insertions, 71 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,
}
diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py
index 93d23cd..6848929 100644
--- a/packages/meshbay-hub/tests/test_moderation.py
+++ b/packages/meshbay-hub/tests/test_moderation.py
@@ -1,34 +1,58 @@
-"""Tests for moderation — reports + blocklist."""
+"""Tests for moderation — reports + blocklist.
+
+Reporting requires a signed-in account (it used to be anonymous, which made it a
+network-wide censorship primitive), the auto-block threshold counts *distinct
+reporting accounts*, and the whole flow is refused when the hub has public groups
+switched off.
+"""
import pytest
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from meshbay_common.crypto import pk_to_b64
from meshbay_hub.api.deps import set_admin_usernames
-
FAKE_HASH = "a" * 64 # valid blake3 hex
-@pytest.fixture
-async def auth_headers(client):
- sk_ed = Ed25519PrivateKey.generate()
- sk_x = X25519PrivateKey.generate()
+async def _register_and_login(client, username: str) -> dict:
await client.post("/v1/users/register", json={
- "username": "mod_admin", "email": "m@t.com", "password": "modpass99",
- "pk_user_ed25519": pk_to_b64(sk_ed.public_key()),
- "pk_user_x25519": pk_to_b64(sk_x.public_key()),
+ "username": username, "email": f"{username}@t.com",
+ "password": "reporter99pw",
})
r = await client.post("/v1/users/login",
- json={"username": "mod_admin", "password": "modpass99"})
- set_admin_usernames(["mod_admin"])
+ json={"username": username, "password": "reporter99pw"})
return {"Authorization": f"Bearer {r.json()['access_token']}"}
+@pytest.fixture
+async def reporter(client):
+ return await _register_and_login(client, "reporter_one")
+
+
+@pytest.fixture
+async def admin_headers(client):
+ headers = await _register_and_login(client, "mod_admin")
+ set_admin_usernames(["mod_admin"])
+ return headers
+
+
@pytest.mark.asyncio
-async def test_report_content_logged(client):
+async def test_report_requires_auth(client):
+ # No credentials at all — FastAPI rejects the missing header before the body.
r = await client.post("/v1/reports", json={
"content_hash": FAKE_HASH, "reason": "illegal"})
+ assert r.status_code in (401, 422)
+
+ # A bogus token is a clean 401.
+ r = await client.post("/v1/reports",
+ json={"content_hash": FAKE_HASH, "reason": "illegal"},
+ headers={"Authorization": "Bearer not-a-real-token"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_report_content_logged(client, reporter):
+ r = await client.post("/v1/reports",
+ json={"content_hash": FAKE_HASH, "reason": "illegal"},
+ headers=reporter)
assert r.status_code == 201
data = r.json()
assert data["report_count"] == 1
@@ -36,43 +60,68 @@ async def test_report_content_logged(client):
@pytest.mark.asyncio
-async def test_auto_block_on_threshold(client):
- """Second report triggers auto-block."""
- hash2 = "b" * 64
- await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"})
- r = await client.post("/v1/reports", json={"content_hash": hash2, "reason": "spam"})
+async def test_same_reporter_cannot_walk_the_threshold(client, reporter):
+ h = "b" * 64
+ for _ in range(5):
+ r = await client.post("/v1/reports",
+ json={"content_hash": h, "reason": "spam"},
+ headers=reporter)
+ assert r.json()["report_count"] == 1
+ assert r.json()["status"] == "already_reported"
+
+ check = await client.get(f"/v1/blocklist/check?hash={h}")
+ assert check.json()["blocked"] is False
+
+
+@pytest.mark.asyncio
+async def test_auto_block_on_distinct_reporters(client):
+ h = "c" * 64
+ for i in range(3):
+ headers = await _register_and_login(client, f"rep_{i}")
+ r = await client.post("/v1/reports",
+ json={"content_hash": h, "reason": "illegal"},
+ headers=headers)
assert r.json()["status"] == "auto_blocked"
- assert r.json()["report_count"] == 2
+ assert r.json()["report_count"] == 3
+
+ check = await client.get(f"/v1/blocklist/check?hash={h}")
+ assert check.json()["blocked"] is True
@pytest.mark.asyncio
-async def test_blocklist_check(client):
- hash3 = "c" * 64
- # Not blocked yet
- r = await client.get(f"/v1/blocklist/check?hash={hash3}")
- assert r.json()["blocked"] is False
+async def test_reports_refused_when_public_groups_disabled(client, reporter, admin_headers):
+ await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": False},
+ headers=admin_headers)
- # Report twice to auto-block
- await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"})
- await client.post("/v1/reports", json={"content_hash": hash3, "reason": "illegal"})
+ r = await client.post("/v1/reports",
+ json={"content_hash": "d" * 64, "reason": "illegal"},
+ headers=reporter)
+ assert r.status_code == 403
- r = await client.get(f"/v1/blocklist/check?hash={hash3}")
- assert r.json()["blocked"] is True
+
+@pytest.mark.asyncio
+async def test_invalid_hash_rejected(client, reporter):
+ r = await client.post("/v1/reports",
+ json={"content_hash": "not-a-valid-blake3-hash",
+ "reason": "test"},
+ headers=reporter)
+ assert r.status_code == 422
@pytest.mark.asyncio
-async def test_admin_add_remove_blocklist(client, auth_headers):
- hash4 = "d" * 64
+async def test_admin_add_remove_blocklist(client, admin_headers):
+ hash4 = "e" * 64
r = await client.post("/v1/admin/blocklist",
json={"content_hash": hash4, "reason": "csam"},
- headers=auth_headers)
+ headers=admin_headers)
assert r.status_code == 201
r = await client.get(f"/v1/blocklist/check?hash={hash4}")
assert r.json()["blocked"] is True
- r = await client.delete(f"/v1/admin/blocklist/{hash4}", headers=auth_headers)
+ r = await client.delete(f"/v1/admin/blocklist/{hash4}", headers=admin_headers)
assert r.status_code == 200
r = await client.get(f"/v1/blocklist/check?hash={hash4}")
@@ -80,18 +129,11 @@ async def test_admin_add_remove_blocklist(client, auth_headers):
@pytest.mark.asyncio
-async def test_invalid_hash_rejected(client):
- r = await client.post("/v1/reports", json={
- "content_hash": "not-a-valid-blake3-hash", "reason": "test"})
- assert r.status_code == 422
-
-
-@pytest.mark.asyncio
-async def test_full_blocklist(client, auth_headers):
- hash5 = "e" * 64
+async def test_full_blocklist(client, admin_headers):
+ hash5 = "f" * 64
await client.post("/v1/admin/blocklist",
json={"content_hash": hash5, "reason": "test"},
- headers=auth_headers)
+ headers=admin_headers)
r = await client.get("/v1/blocklist")
assert r.status_code == 200
assert hash5 in r.json()["hashes"]