aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 21:51:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 21:51:25 +0200
commit799d87999c8324564dce5159191532e008dd93d2 (patch)
tree5ff1816f18625dfece9eb67fa06c7b25fdece4f8 /packages/meshbay-hub
parent8a6294b0412a86f378c6e2e937c28de64a903c91 (diff)
parent1e6db7d23c70b7bd7e1422f09911b3645f0fb2e2 (diff)
downloadmeshbay-799d87999c8324564dce5159191532e008dd93d2.tar.gz
Merge branch 'fix/third-review-h1-h2-m1-m6'
Third security review (docs/third-review.md) plus its remediation. Fixed and verified: - H1 moderator could grant admin / hard-revoke → handler split by field - H2 unauthenticated 2-report global blocklist → auth + distinct reporters + rate limit + refused when public groups are off - M1 registration reCAPTCHA was inert → gate unconditional; the desktop client's CSP allows the widget - M2 QUIC chat/stream handlers lagged WebRTC → brought to parity; the QUIC listener is now off by default ([node] quic_enabled) - M3 link-preview SSRF gaps → rate limit + port allowlist + connect-address re-check + decompression-bomb guard - M4 federated peer over-trust → source bound to the signer, push capped, revocation prunes the peer's own entries, replay rejected - M5 no CSP / security headers on the SPA → middleware; verified against the live app with no violations Withdrawn: - M6 add_group_member accepting node tokens is deliberate (commit 0443cf8, the CLI invite flow). The "fix" broke that flow on the deployed hub and was reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py124
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py86
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js6
-rw-r--r--packages/meshbay-hub/tests/test_admin.py39
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py42
-rw-r--r--packages/meshbay-hub/tests/test_federation.py179
-rw-r--r--packages/meshbay-hub/tests/test_moderation.py132
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py23
-rw-r--r--packages/meshbay-hub/tests/test_register_captcha.py58
-rw-r--r--packages/meshbay-hub/tests/test_security_headers.py65
17 files changed, 745 insertions, 120 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index ab6fa07..4960674 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -14,7 +14,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import decrypt_email
-from meshbay_hub.api.deps import require_admin, require_moderator
+from meshbay_hub.api.deps import require_admin, require_moderator, user_is_admin
from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
@@ -196,6 +196,25 @@ async def admin_patch_user(
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own account")
+ # A moderator suspends and restores accounts — reversible content moderation.
+ # Changing what someone *is* (their role), and the one irreversible status
+ # (`revoked`, which is signed and broadcast to every node), are administrative.
+ # Without this split a moderator could promote an accomplice to admin, or
+ # revoke every admin, entirely from the moderation role. `admin_delete_user`
+ # already draws this exact line for the same reason.
+ privileged = body.role is not None or body.status == "revoked"
+ if privileged and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Changing a role, or revoking an account, requires admin rights")
+
+ # An admin's account is not a moderator's to touch at all — not their role,
+ # not their status.
+ if user_is_admin(user) and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change another admin's account")
+
from meshbay_hub.api.notifications import create_notification
if body.role is not None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 1bf57a4..907a481 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -72,11 +72,21 @@ async def require_user_scope(
return current_user
+def user_is_admin(user: User) -> bool:
+ """Admin by DB role or by the config allow-list. Use inside a handler that
+ already depends on `require_moderator` but has to draw the admin line for
+ one field (see `admin_patch_user`)."""
+ return user.role == "admin" or user.username in _admin_usernames
+
+
+def user_is_moderator(user: User) -> bool:
+ return user.role in ("moderator", "admin") or user.username in _admin_usernames
+
+
async def require_moderator(
current_user: User = Depends(get_current_user),
) -> User:
- if current_user.role not in ("moderator", "admin") \
- and current_user.username not in _admin_usernames:
+ if not user_is_moderator(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator access required")
return current_user
@@ -85,8 +95,7 @@ async def require_moderator(
async def require_admin(
current_user: User = Depends(get_current_user),
) -> User:
- if current_user.role != "admin" \
- and current_user.username not in _admin_usernames:
+ if not user_is_admin(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
return current_user
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index 7f1262d..b1e0e30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -27,7 +27,7 @@ import uuid
import jwt
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_common import MHP_VERSION
@@ -41,6 +41,17 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/mhp", tags=["federation"])
+# One push may not dump the world, and one peer may not fill the table.
+MAX_FEDERATED_GROUPS_PER_PUSH = 500
+MAX_FEDERATED_GROUPS_PER_PEER = 2000
+
+# Seen `jti` values for the state-changing MHP endpoints, pruned lazily. The
+# sending side that would set an `aud` claim is unbuilt, so audience binding is
+# not available; this stops a captured POST /mhp/directory or /mhp/revoke from
+# being replayed inside the token's short TTL. GET /mhp/directory is idempotent
+# and not covered.
+_seen_mhp_jti: dict[str, float] = {}
+
def _issue_mhp_token(target_hub_id: str) -> str:
"""Issue a short-lived JWT for authenticating to a peer hub."""
@@ -57,9 +68,15 @@ def _issue_mhp_token(target_hub_id: str) -> str:
async def _verify_mhp_token(
- token: str, db: AsyncSession, expected_aud: str | None = None,
+ token: str, db: AsyncSession, *, single_use: bool = False,
) -> dict:
- """Verify a JWT from a peer hub using DB-stored public key."""
+ """
+ Verify a JWT from a peer hub against its DB-stored public key and return the
+ payload.
+
+ `single_use=True` (the state-changing endpoints) additionally rejects a
+ replayed `jti` within the token's lifetime.
+ """
unverified = jwt.decode(token, options={"verify_signature": False})
sender_id = unverified.get("iss")
@@ -67,15 +84,22 @@ async def _verify_mhp_token(
if not peer:
raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.")
- options = {}
- if expected_aud:
- options["audience"] = expected_aud
-
decoded = jwt.decode(
token, peer.pk_hub_pem.encode(),
algorithms=["EdDSA"],
- options=options,
+ options={"require": ["exp", "iss"]},
)
+
+ if single_use:
+ now = time.time()
+ for j, exp in list(_seen_mhp_jti.items()):
+ if exp < now:
+ _seen_mhp_jti.pop(j, None)
+ jti = decoded.get("jti", "")
+ if not jti or jti in _seen_mhp_jti:
+ raise PermissionError("MHP token replay")
+ _seen_mhp_jti[jti] = float(decoded.get("exp", now + 300))
+
return decoded
@@ -142,30 +166,50 @@ async def receive_directory(
db: AsyncSession = Depends(get_db),
):
try:
- await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
+ payload = await _verify_mhp_token(
+ authorization.removeprefix("Bearer "), db, single_use=True)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
+ # `source_hub` is the signer of this request, never `body.hub_id` — a peer
+ # does not get to relay or spoof a third hub's groups into our directory.
+ sender = payload["iss"]
+ if len(body.groups) > MAX_FEDERATED_GROUPS_PER_PUSH:
+ raise HTTPException(status_code=413, detail="Too many groups in one push")
+
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
+ have = await db.scalar(
+ select(func.count()).select_from(FederatedGroup)
+ .where(FederatedGroup.source_hub == sender)) or 0
+
count = 0
for g in body.groups:
- existing = await db.get(FederatedGroup, g["id"])
- if existing:
- existing.name = g.get("name", existing.name)
- existing.join_policy = g.get("join_policy", existing.join_policy)
- existing.updated_at = now
+ gid = str(g.get("id", ""))[:36]
+ name = str(g.get("name", ""))[:128]
+ jp = g.get("join_policy", "invite")
+ if not gid or jp not in ("invite", "open"):
+ continue
+ # A federated id must never shadow a real local group.
+ if await db.get(Group, gid):
+ log.warning("Federated id %s collides with a local group — skipped", gid[:8])
+ continue
+ row = await db.get(FederatedGroup, gid)
+ if row:
+ if row.source_hub != sender:
+ continue # only the hub that advertised it may update it
+ row.name = name or row.name
+ row.join_policy = jp
+ row.updated_at = now
else:
+ if have + count >= MAX_FEDERATED_GROUPS_PER_PEER:
+ break
db.add(FederatedGroup(
- id=g["id"],
- name=g.get("name", ""),
- source_hub=body.hub_id,
- join_policy=g.get("join_policy", "invite"),
- ))
+ id=gid, name=name, source_hub=sender, join_policy=jp))
count += 1
await db.commit()
- log.info("Persisted %d groups from hub %s", count, body.hub_id[:16])
- return {"accepted": count, "from_hub": body.hub_id}
+ log.info("Persisted %d groups from hub %s", count, sender[:16])
+ return {"accepted": count, "from_hub": sender}
# ── Revocation propagation ────────────────────────────────────────────────────
@@ -179,15 +223,43 @@ async def receive_revocation(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
+ """
+ Act on a revocation from a peer hub.
+
+ This does **not** reach local nodes: nothing here hosts a federated group,
+ and a local node would reject a token signed by another hub's key anyway
+ (that path was a silent no-op). What a peer may legitimately revoke is a
+ group *it advertised to us* — so this prunes our copy of the peer's
+ directory. Users are per-hub; a peer does not get to revoke ours.
+ """
try:
- await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
+ payload = await _verify_mhp_token(
+ authorization.removeprefix("Bearer "), db, single_use=True)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
- from meshbay_hub.api.revocation import broadcast_revocation
- sent = await broadcast_revocation(body.token)
- log.info("Propagated revocation to %d local nodes", sent)
- return {"propagated_to": sent}
+ sender = payload["iss"]
+ peer = await db.get(HubPeer, sender)
+ try:
+ inner = jwt.decode(
+ body.token, peer.pk_hub_pem.encode(), algorithms=["EdDSA"],
+ options={"verify_exp": False})
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Bad revocation token: {e}")
+
+ if inner.get("type") != "revocation" or inner.get("target") != "group":
+ return {"pruned": 0, "note": "federation may only revoke groups it advertised"}
+
+ target_id = inner.get("target_id", "")
+ row = await db.get(FederatedGroup, target_id)
+ pruned = 0
+ if row and row.source_hub == sender:
+ await db.delete(row)
+ await db.commit()
+ pruned = 1
+ log.info("Federated group %s revoked by %s (pruned=%d)",
+ target_id[:8], sender[:16], pruned)
+ return {"pruned": pruned}
# ── Peer management (admin) ───────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index fdb0444..07d3ec0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -589,6 +589,13 @@ async def add_group_member(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ # `get_current_user`, not `require_user_scope`: the node calls this after a
+ # CLI `member invite` so the group becomes visible in the invitee's SPA
+ # (commit 0443cf8). The node authenticates with a node-scoped token, and the
+ # `group.admin_id == current_user.id` check below is the real guard — a node
+ # can only touch its own operator's groups, adding an already-registered
+ # account. (Third-review M6 proposed tightening this to `require_user_scope`;
+ # that broke the CLI invite flow and was reverted — see the review doc.)
group = await db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
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/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 559cfa6..9b70b59 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -150,8 +150,13 @@ async def register(
return {"user_id": found.id, "email_verification_required": True}
raise HTTPException(status_code=409, detail="Username already taken")
- # Captcha gate — web path only (native clients send auth_key)
- if _cfg and _cfg.captcha.enabled and not body.auth_key:
+ # Captcha gate — every fresh registration when a captcha is configured, with
+ # no client carve-out. The earlier `and not body.auth_key` exempted anything
+ # that sent an `auth_key`, which is *every* real client (the browser sends it
+ # too, from the password split) — so the check was off for everyone, and a
+ # bot skipped it by sending the field. The desktop client is Chromium and
+ # renders the same widget, so it has no need of an exemption either.
+ if _cfg and _cfg.captcha.enabled:
await _verify_captcha_or_raise(body.captcha_token, request)
# Email uniqueness (only active or pending accounts)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 0821809..6dfd3ed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -22,7 +22,8 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
# Assets the shell pulls in, in load order. Everything else is imported by
-# app.js and rides on the same query string via window.__MB_ASSET_V.
+# app.js from a relative path, which inherits the `/a/<hash>/` prefix the shell
+# loaded app.js under — so the whole module graph moves together.
# Every module the page loads. A file missing from here is a file whose change
# does not move the URL, so a browser holding the old one never asks for it —
# which is the failure this list exists to prevent, and it is silent.
@@ -77,6 +78,33 @@ ASSET_V = _asset_version()
_NO_STORE = {"Cache-Control": "no-store"}
+# Content-Security-Policy for the whole hub, applied by a middleware in app.py.
+#
+# This is the *same* policy the desktop client's protocol handler already sends
+# for these exact UI files (`meshbay-client/src/main.js`), plus the two reCAPTCHA
+# hosts the sign-up widget loads its script, challenge iframe and images from.
+# `'unsafe-inline'` is style-only — htm/preact set inline `style=` attributes
+# everywhere; nothing inline executes, and the shell below carries no inline
+# `<script>`. `'wasm-unsafe-eval'` is required for the Argon2id WASM. The hub's
+# own origin is deliberately absent from `script-src`: a response it returns is
+# never executed, which is the point of T3.
+_RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"
+CSP = "; ".join([
+ "default-src 'none'",
+ f"script-src 'self' 'wasm-unsafe-eval' {_RECAPTCHA_SRC}",
+ "style-src 'self' 'unsafe-inline'",
+ f"img-src 'self' data: blob: {_RECAPTCHA_SRC}",
+ "media-src 'self' blob:",
+ "font-src 'self'",
+ "connect-src 'self' https: wss:",
+ "worker-src 'self'",
+ f"frame-src {_RECAPTCHA_SRC}",
+ "frame-ancestors 'none'",
+ "base-uri 'none'",
+ "form-action 'none'",
+])
+
+
@router.get("/app", response_class=HTMLResponse)
async def app_root():
return HTMLResponse(_HTML, headers=_NO_STORE)
@@ -109,7 +137,6 @@ _HTML = """\
and app.js's own relative imports inherit the prefix, which is the only
way the module graph is guaranteed not to be a mixture of two builds.
See _asset_version() and VersionedStatics. -->
- <script>window.__MB_ASSET_V = "{v}";</script>
<!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
keypair bundle needs one: it is protected by the passphrase alone and sits
on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 76d7ec0..2daa55b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -35,7 +35,7 @@ from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.admin import router as admin_router
from meshbay_hub.api.notifications import router as notifications_router
-from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V
+from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V, CSP
from meshbay_hub.api.middleware import limiter
@@ -142,6 +142,21 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+ @app.middleware("http")
+ async def _security_headers(request, call_next):
+ """
+ Second-review L5, third-review M5: the SPA shell and its assets went out
+ with no CSP and no other protective headers. This adds them everywhere —
+ `webapp.CSP` is the same policy the desktop client already enforces on
+ these exact files. `setdefault` so a route that sets its own wins.
+ """
+ response = await call_next(request)
+ response.headers.setdefault("Content-Security-Policy", CSP)
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
+ response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
+ response.headers.setdefault("X-Frame-Options", "DENY")
+ return response
+
# Routers (webapp last — catches / before API routes)
app.include_router(hub_router)
app.include_router(users_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index df08bc4..4c00137 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -237,8 +237,11 @@ export function RegisterPage() {
const rk = window.MeshBayKeys.generateRecoveryKey();
// `name` (trimmed), not the raw field: the hub stores the trimmed
// username and every key derivation must fold in the same string.
+ // `captcha.token` rides along — the submit button is already disabled
+ // until it is set when a captcha is configured (see the form below).
await window.MeshBayKeys.registerUser(
- name, email, password, emailRecovery ? rk.mnemonic : null);
+ name, email, password, emailRecovery ? rk.mnemonic : null,
+ captcha.token);
setRecoveryMnemonic(rk.mnemonic);
session.recoveryKey =
await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name);
@@ -257,6 +260,10 @@ export function RegisterPage() {
}
} catch (err) {
setError(err.message);
+ // A reCAPTCHA token is single-use: after a failed attempt (name taken,
+ // e-mail in use…) it is spent, so clear it and make the user solve a
+ // fresh one before the next try. No-op when no captcha is configured.
+ captcha.reset();
} finally {
setLoading(false);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 0aaa6a5..a540a94 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -276,7 +276,7 @@ async function decryptBundle(bundleB64, password, username) {
*
* Returns the raw private keys for immediate use after registration.
*/
-async function registerUser(username, email, password, recoveryMnemonic) {
+async function registerUser(username, email, password, recoveryMnemonic, captchaToken) {
// No keypair here any more. Identity keys are per node: one is generated the
// first time this account joins a given node, encrypted under the passphrase,
// and left with that node. So an operator who cracks what sits on their own
@@ -291,6 +291,10 @@ async function registerUser(username, email, password, recoveryMnemonic) {
// appends it to the verification e-mail and stores it nowhere
// (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves.
if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic;
+ // reCAPTCHA response, when the hub has a captcha configured. The widget lives
+ // in RegisterPage (auth-page.js); this function just forwards its token. A
+ // hub with no captcha configured sends nothing and the server does not check.
+ if (captchaToken) payload.captcha_token = captchaToken;
const resp = await hubCall('/v1/users/register', {
method: 'POST',
diff --git a/packages/meshbay-hub/tests/test_admin.py b/packages/meshbay-hub/tests/test_admin.py
index 51b233a..ad48487 100644
--- a/packages/meshbay-hub/tests/test_admin.py
+++ b/packages/meshbay-hub/tests/test_admin.py
@@ -5,7 +5,6 @@ Integration tests for the admin/moderation panel API.
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
@@ -162,6 +161,44 @@ async def test_admin_change_role(client):
@pytest.mark.asyncio
+async def test_moderator_cannot_change_roles_or_revoke(client):
+ """A moderator suspends and restores (reversible); it cannot promote anyone
+ or hard-revoke, which would be a path from the moderation role to full
+ instance control."""
+ _, admin_token = await _setup_admin(client, "boss")
+ mod_id = await _register(client, "moduser")
+ await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+ mod_token = await _login(client, "moduser")
+ mod_h = {"Authorization": f"Bearer {mod_token}"}
+
+ victim = await _register(client, "victim", email="v@x.com")
+
+ # No promoting an accomplice.
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"role": "admin"},
+ headers=mod_h)
+ assert r.status_code == 403
+
+ # No hard revocation.
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"},
+ headers=mod_h)
+ assert r.status_code == 403
+
+ # No touching an admin's account.
+ admin2 = await _register(client, "admin2", email="a2@x.com")
+ await client.patch(f"/v1/admin/users/{admin2}", json={"role": "admin"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+ r = await client.patch(f"/v1/admin/users/{admin2}", json={"status": "suspended"},
+ headers=mod_h)
+ assert r.status_code == 403
+
+ # Suspending a plain user is still fine.
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "suspended"},
+ headers=mod_h)
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
async def test_admin_cannot_modify_self(client):
admin_id, token = await _setup_admin(client)
r = await client.patch(f"/v1/admin/users/{admin_id}",
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index 36b261e..b82804e 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -175,11 +175,17 @@ def _policy() -> str:
"""
import re
source = _main()
+ # The array mixes plain strings and one `${RECAPTCHA_SRC}` template literal;
+ # resolve the constant so every directive reads as plain text.
+ rec = re.search(r"const RECAPTCHA_SRC = '([^']*)'", source)
match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S)
assert match, "no CSP constant in the main process"
+ body = match.group(1)
+ if rec:
+ body = body.replace("${RECAPTCHA_SRC}", rec.group(1))
return "; ".join(
- line.strip().strip('",').strip('"')
- for line in match.group(1).splitlines() if line.strip())
+ line.strip().strip('`",').strip('`"')
+ for line in body.splitlines() if line.strip())
def _directive(name: str) -> str:
@@ -193,18 +199,46 @@ def _directive(name: str) -> str:
def test_the_hub_is_reachable_but_never_executable():
"""
connect-src allows the hub's API and its signaling socket. script-src does
- not include it: nothing the hub returns is ever executed.
+ not: nothing the hub returns is ever executed. The only script sources are
+ 'self', the wasm eval token, and the two reCAPTCHA hosts (see the next
+ test) — never a bare `https:` scheme, which would let the hub's own origin
+ serve script.
"""
connect = _directive("connect-src")
assert "https:" in connect and "wss:" in connect
script = _directive("script-src")
assert script, "no script-src directive"
- assert "https:" not in script, "the hub can serve script under this policy"
+ sources = script.split()[1:] # drop the "script-src" keyword itself
+ allowed = {
+ "'self'", "'wasm-unsafe-eval'",
+ "https://www.google.com", "https://www.gstatic.com",
+ }
+ assert set(sources) <= allowed, \
+ f"unexpected script-src source: {set(sources) - allowed}"
+ assert "https:" not in sources, "a bare https: scheme lets the hub serve script"
assert "'unsafe-eval'" not in script.replace("'wasm-unsafe-eval'", "")
assert "default-src 'none'" in _policy()
+def test_recaptcha_is_the_only_third_party_and_stays_scoped_to_it():
+ """
+ reCAPTCHA gates sign-up in the app the same way it does in the browser.
+ www.google.com and www.gstatic.com are allowed under script-src, frame-src
+ and img-src for that — and no other external origin appears anywhere in the
+ policy. Remove this expectation only alongside the reCAPTCHA widget.
+ """
+ hosts = {"https://www.google.com", "https://www.gstatic.com"}
+ for directive in ("script-src", "frame-src", "img-src"):
+ srcs = set(_directive(directive).split()[1:])
+ assert hosts <= srcs, f"{directive} is missing a reCAPTCHA host"
+
+ for part in _policy().split(";"):
+ for tok in part.strip().split()[1:]:
+ if tok.startswith(("http://", "https://")):
+ assert tok in hosts, f"unexpected external origin in CSP: {tok}"
+
+
# ── The bridge ──────────────────────────────────────────────────────────────
def test_the_bridge_is_the_only_way_in():
diff --git a/packages/meshbay-hub/tests/test_federation.py b/packages/meshbay-hub/tests/test_federation.py
new file mode 100644
index 0000000..6035b0c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_federation.py
@@ -0,0 +1,179 @@
+"""
+MHP federation — what a registered peer hub may and may not do.
+
+A peer is trusted enough to advertise its own public groups into our directory
+and to withdraw them. It is not trusted to speak for a third hub, to shadow a
+local group, to revoke our users, or to replay a state-changing request.
+"""
+
+import base64
+import hashlib
+import time
+import uuid
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _admin(client, username="root"):
+ pw = "a-long-enough-passphrase"
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(pw, username)})
+ set_admin_usernames([username])
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(pw, username)})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+class Peer:
+ def __init__(self, hub_id: str):
+ self.hub_id = hub_id
+ self._sk = Ed25519PrivateKey.generate()
+ self.pk_pem = self._sk.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo).decode()
+
+ def _sk_pem(self) -> bytes:
+ return self._sk.private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption())
+
+ def envelope(self, jti: str | None = None) -> str:
+ now = int(time.time())
+ return jwt.encode(
+ {"iss": self.hub_id, "sub": self.hub_id,
+ "jti": jti or str(uuid.uuid4()), "iat": now, "exp": now + 300},
+ self._sk_pem(), algorithm="EdDSA")
+
+ def revocation(self, target: str, target_id: str) -> str:
+ return jwt.encode(
+ {"type": "revocation", "target": target, "target_id": target_id,
+ "iss": self.hub_id, "iat": int(time.time())},
+ self._sk_pem(), algorithm="EdDSA")
+
+ def header(self, **kw) -> dict:
+ return {"Authorization": f"Bearer {self.envelope(**kw)}"}
+
+
+async def _register_peer(client, admin, peer: Peer):
+ r = await client.post("/mhp/peers", headers=admin, json={
+ "hub_id": peer.hub_id, "hub_url": f"https://{peer.hub_id}",
+ "pk_hub_pem": peer.pk_pem})
+ assert r.status_code == 201, r.text
+
+
+# ── receive_directory ──────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_unknown_peer_is_refused(client):
+ stranger = Peer("nobody.example")
+ r = await client.post("/mhp/directory", headers=stranger.header(),
+ json={"hub_id": "nobody.example", "groups": []})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_source_hub_is_the_signer_not_the_body(client):
+ admin = await _admin(client)
+ peer = Peer("peer-a.example")
+ await _register_peer(client, admin, peer)
+
+ r = await client.post("/mhp/directory", headers=peer.header(), json={
+ "hub_id": "peer-b.example", # claims to relay another hub
+ "groups": [{"id": "g-1", "name": "Shared", "join_policy": "open"}]})
+ assert r.status_code == 202
+
+ listing = (await client.get("/v1/groups")).json()["groups"]
+ row = next(g for g in listing if g["id"] == "g-1")
+ assert row["source"] == "peer-a.example" # the signer, not "peer-b.example"
+
+
+@pytest.mark.asyncio
+async def test_a_federated_id_cannot_shadow_a_local_group(client):
+ admin = await _admin(client)
+ peer = Peer("peer-a.example")
+ await _register_peer(client, admin, peer)
+
+ owner = await _admin(client, "owner")
+ r = await client.post("/v1/groups", headers=owner, json={
+ "name": "mine", "visibility": "public", "join_policy": "open"})
+ local_id = r.json()["group_id"]
+
+ r = await client.post("/mhp/directory", headers=peer.header(), json={
+ "hub_id": peer.hub_id,
+ "groups": [{"id": local_id, "name": "evil twin", "join_policy": "open"}]})
+ assert r.status_code == 202
+ assert r.json()["accepted"] == 0
+
+
+@pytest.mark.asyncio
+async def test_a_state_changing_token_cannot_be_replayed(client):
+ admin = await _admin(client)
+ peer = Peer("peer-a.example")
+ await _register_peer(client, admin, peer)
+
+ env = peer.envelope(jti="fixed-jti")
+ h = {"Authorization": f"Bearer {env}"}
+ body = {"hub_id": peer.hub_id,
+ "groups": [{"id": "g-9", "name": "Once", "join_policy": "open"}]}
+
+ assert (await client.post("/mhp/directory", headers=h, json=body)).status_code == 202
+ assert (await client.post("/mhp/directory", headers=h, json=body)).status_code == 401
+
+
+# ── receive_revocation ─────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_peer_may_withdraw_its_own_group(client):
+ admin = await _admin(client)
+ peer = Peer("peer-a.example")
+ await _register_peer(client, admin, peer)
+
+ await client.post("/mhp/directory", headers=peer.header(), json={
+ "hub_id": peer.hub_id,
+ "groups": [{"id": "g-77", "name": "Bye", "join_policy": "open"}]})
+ assert any(g["id"] == "g-77" for g in (await client.get("/v1/groups")).json()["groups"])
+
+ r = await client.post("/mhp/revoke", headers=peer.header(),
+ json={"token": peer.revocation("group", "g-77")})
+ assert r.status_code == 202 and r.json()["pruned"] == 1
+ assert not any(g["id"] == "g-77" for g in (await client.get("/v1/groups")).json()["groups"])
+
+
+@pytest.mark.asyncio
+async def test_a_peer_cannot_withdraw_another_hubs_group(client):
+ admin = await _admin(client)
+ a, b = Peer("peer-a.example"), Peer("peer-b.example")
+ await _register_peer(client, admin, a)
+ await _register_peer(client, admin, b)
+
+ await client.post("/mhp/directory", headers=a.header(), json={
+ "hub_id": a.hub_id,
+ "groups": [{"id": "g-a", "name": "A's", "join_policy": "open"}]})
+
+ # b signs a revocation for a's group and presents it under b's envelope.
+ r = await client.post("/mhp/revoke", headers=b.header(),
+ json={"token": b.revocation("group", "g-a")})
+ assert r.status_code == 202 and r.json()["pruned"] == 0
+ assert any(g["id"] == "g-a" for g in (await client.get("/v1/groups")).json()["groups"])
+
+
+@pytest.mark.asyncio
+async def test_federation_cannot_revoke_a_user(client):
+ admin = await _admin(client)
+ peer = Peer("peer-a.example")
+ await _register_peer(client, admin, peer)
+
+ r = await client.post("/mhp/revoke", headers=peer.header(),
+ json={"token": peer.revocation("user", "some-user-id")})
+ assert r.status_code == 202 and r.json()["pruned"] == 0
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"]
diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py
index 72ce412..a104a72 100644
--- a/packages/meshbay-hub/tests/test_node_auth.py
+++ b/packages/meshbay-hub/tests/test_node_auth.py
@@ -148,24 +148,35 @@ async def test_node_scope_blocks_group_create(client):
@pytest.mark.asyncio
-async def test_node_scope_blocks_add_member(client):
- sk_node, user_token = await _setup_node_user(client, "op1")
+async def test_node_token_may_add_a_member_to_its_own_operators_group(client):
+ """The node calls this after a CLI `member invite` so the group shows up in
+ the invitee's SPA (commit 0443cf8). A node-scoped token is accepted here —
+ the `group.admin_id == caller` check is the guard — but only for a group the
+ node's operator owns."""
+ sk_op, op_token = await _setup_node_user(client, "op1")
r = await client.post("/v1/groups", json={
"name": "mygroup", "visibility": "private", "join_policy": "invite",
- }, headers={"Authorization": f"Bearer {user_token}"})
- assert r.status_code == 201
+ }, headers={"Authorization": f"Bearer {op_token}"})
gid = r.json()["group_id"]
_, pk2 = _gen_ed25519()
_, px2 = _gen_x25519()
await _register(client, "member1", pk2, px2)
- r = await _node_auth(client, "op1", sk_node)
- node_token = r.json()["access_token"]
+ node_token = (await _node_auth(client, "op1", sk_op)).json()["access_token"]
r = await client.post(f"/v1/groups/{gid}/members/member1",
headers={"Authorization": f"Bearer {node_token}"})
+ assert r.status_code == 201
+
+ # …but not to a group it does not own.
+ sk_other, other_token = await _setup_node_user(client, "op2")
+ r = await client.post("/v1/groups", json={"name": "theirs", "visibility": "private"},
+ headers={"Authorization": f"Bearer {other_token}"})
+ other_gid = r.json()["group_id"]
+ r = await client.post(f"/v1/groups/{other_gid}/members/member1",
+ headers={"Authorization": f"Bearer {node_token}"})
assert r.status_code == 403
diff --git a/packages/meshbay-hub/tests/test_register_captcha.py b/packages/meshbay-hub/tests/test_register_captcha.py
new file mode 100644
index 0000000..befc1e2
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_register_captcha.py
@@ -0,0 +1,58 @@
+"""Registration CAPTCHA is enforced for every fresh account when configured.
+
+The gate used to be skipped whenever the request carried an `auth_key` — which
+every real client sends (the password split) — so it protected nobody and a bot
+skipped it by including the field. It now runs on `captcha.enabled` alone; the
+desktop client is Chromium and renders the same widget.
+"""
+
+import pytest
+
+
+@pytest.fixture
+def captcha_on(client, monkeypatch):
+ """Turn on a fake captcha: any config with both keys is `enabled`, and
+ verification succeeds only for the token 'good-token'."""
+ from meshbay_hub.api.users import _cfg
+ monkeypatch.setattr(_cfg.captcha, "site_key", "test-site")
+ monkeypatch.setattr(_cfg.captcha, "secret_key", "test-secret")
+
+ async def fake_verify(secret, token, remote_ip=None):
+ return token == "good-token"
+
+ monkeypatch.setattr("meshbay_hub.captcha.verify_captcha", fake_verify)
+
+
+def _body(**over):
+ b = {"username": "newbie", "email": "newbie@t.com", "auth_key": "a" * 44}
+ b.update(over)
+ return b
+
+
+@pytest.mark.asyncio
+async def test_missing_captcha_rejected_even_with_auth_key(client, captcha_on):
+ r = await client.post("/v1/users/register", json=_body())
+ assert r.status_code == 400
+ assert r.json()["detail"] == "captcha_required"
+
+
+@pytest.mark.asyncio
+async def test_bad_captcha_rejected(client, captcha_on):
+ r = await client.post("/v1/users/register",
+ json=_body(captcha_token="wrong"))
+ assert r.status_code == 400
+ assert r.json()["detail"] == "captcha_failed"
+
+
+@pytest.mark.asyncio
+async def test_good_captcha_accepted(client, captcha_on):
+ r = await client.post("/v1/users/register",
+ json=_body(captcha_token="good-token"))
+ assert r.status_code == 201
+
+
+@pytest.mark.asyncio
+async def test_no_captcha_configured_still_registers(client):
+ # Default test config has no captcha keys — registration proceeds without one.
+ r = await client.post("/v1/users/register", json=_body())
+ assert r.status_code == 201
diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py
new file mode 100644
index 0000000..b4d7e6d
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_security_headers.py
@@ -0,0 +1,65 @@
+"""
+The hub sends a Content-Security-Policy and the other protective headers on
+every response — the SPA shell, its assets, and the API alike.
+
+Second-review L5 / third-review M5: previously there were none, so an injection
+that landed in the SPA (rendered third-party OG data, a federated group name,
+chat content) had nothing stopping it from loading more code or exfiltrating.
+"""
+
+import pytest
+from meshbay_hub.api.webapp import CSP
+
+
+def _directive(csp: str, name: str) -> str:
+ for part in csp.split(";"):
+ part = part.strip()
+ if part == name or part.startswith(name + " "):
+ return part
+ return ""
+
+
+@pytest.mark.asyncio
+async def test_the_spa_shell_carries_the_policy(client):
+ r = await client.get("/")
+ assert r.headers["content-security-policy"] == CSP
+ assert r.headers["x-content-type-options"] == "nosniff"
+ assert r.headers["x-frame-options"] == "DENY"
+ assert "referrer-policy" in r.headers
+
+
+@pytest.mark.asyncio
+async def test_the_api_carries_the_headers_too(client):
+ r = await client.get("/v1/health")
+ assert r.status_code == 200
+ assert "content-security-policy" in r.headers
+ assert r.headers["x-content-type-options"] == "nosniff"
+
+
+@pytest.mark.asyncio
+async def test_even_a_404_carries_the_headers(client):
+ # The middleware runs on every response, so a probe for a missing path
+ # cannot be framed or content-sniffed either.
+ r = await client.get("/no/such/path")
+ assert r.status_code == 404
+ assert r.headers["x-frame-options"] == "DENY"
+
+
+def test_the_policy_is_locked_down_where_it_matters():
+ assert "default-src 'none'" in CSP # covers object-src, etc.
+ assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'none'"
+ assert _directive(CSP, "base-uri") == "base-uri 'none'"
+
+ script = _directive(CSP, "script-src")
+ # The hub's own origin must not be able to serve executable script (T3):
+ # 'self' and the wasm token are fine, a bare `https:` scheme is not.
+ assert "'self'" in script and "'wasm-unsafe-eval'" in script
+ assert "https:" not in script.split()
+
+
+def test_recaptcha_is_the_only_external_origin():
+ hosts = {"https://www.google.com", "https://www.gstatic.com"}
+ for part in CSP.split(";"):
+ for tok in part.strip().split()[1:]:
+ if tok.startswith(("http://", "https://")):
+ assert tok in hosts, f"unexpected external origin in CSP: {tok}"