From c8462c6060ccd2cccb305edcd64ef12a675f2f58 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 12:14:40 +0200 Subject: fix(hub): bound the blocklist reads a stranger can make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /v1/blocklist` takes no authentication — a node syncs it at startup — and had no ceiling on `limit`, so anyone could ask for the table in one query, repeatedly. `GET /v1/blocklist/check` took any string of any length straight into a primary-key lookup, unmetered. Not changed, and worth a decision rather than a quiet edit: `AUTO_BLOCK_THRESHOLD` is 3. Three distinct accounts blocking a hash adds it to the list every node enforces, network-wide, automatically, with manual admin removal the only undo. Far better than the anonymous version it replaced, and still a censorship primitive an attacker buys for three email addresses. §13.5b records the option — count only accounts more than a day old, which costs a patient attacker a day and an honest reporter nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- .../meshbay-hub/src/meshbay_hub/api/moderation.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py index 0939eec..ee10cbc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py @@ -25,7 +25,7 @@ Node integration: import logging from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -138,11 +138,20 @@ async def report_content( @router.get("/v1/blocklist/check") +@limiter.limit("120/minute") async def check_blocklist( hash: str, + request: Request, db: AsyncSession = Depends(get_db), ): - """Check if a single hash is blocked. Used by nodes before serving public content.""" + """Check if a single hash is blocked. Used by nodes before serving public content. + + Unauthenticated, because a node consults it before serving public content + and does so on its own behalf. That makes the shape check worth having: + without it any string of any length became a primary-key lookup. + """ + if len(hash) != 64 or not all(c in "0123456789abcdef" for c in hash): + raise HTTPException(status_code=422, detail="hash must be 64 hex chars (blake3)") blocked = await db.get(ContentBlocklist, hash) return { "blocked": blocked is not None, @@ -154,7 +163,11 @@ async def check_blocklist( @router.get("/v1/blocklist") async def get_blocklist( db: AsyncSession = Depends(get_db), - limit: int = 10000, + # Bounded, like every other list. This one takes no authentication — a + # node syncs it at startup — and had no ceiling at all, so any stranger + # could ask for the table in one query, repeatedly. 10 000 is what a node + # asks for, so it is the default and also the most anyone may have. + limit: int = Query(default=10000, ge=1, le=10000), ): """ Return the full blocklist. Nodes sync this on startup. -- cgit v1.2.3