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 --- packages/meshbay-hub/tests/test_moderation.py | 132 +++++++++++++++++--------- 1 file changed, 87 insertions(+), 45 deletions(-) (limited to 'packages/meshbay-hub/tests/test_moderation.py') 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"] -- cgit v1.2.3