diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 21:51:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 21:51:25 +0200 |
| commit | 799d87999c8324564dce5159191532e008dd93d2 (patch) | |
| tree | 5ff1816f18625dfece9eb67fa06c7b25fdece4f8 /packages/meshbay-hub/tests | |
| parent | 8a6294b0412a86f378c6e2e937c28de64a903c91 (diff) | |
| parent | 1e6db7d23c70b7bd7e1422f09911b3645f0fb2e2 (diff) | |
| download | meshbay-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.py | 39 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_federation.py | 179 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_moderation.py | 132 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_auth.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_register_captcha.py | 58 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_security_headers.py | 65 |
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}" |