aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
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/tests
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/tests')
-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
7 files changed, 482 insertions, 56 deletions
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}"