# MeshBay — Third Architecture & Security Review > Date: 2026-09-01 > Scope: the code as it stands on `main` at `8a6294b`, with emphasis on what > changed since `second-review.md` (2026-08-13): the unified handshake > (`meshbay_common/handshake.py`), device linking, the invite/pairing rewrite, > account recovery and passphrase change (`docs/auth-confirm.md`), email > verification, reCAPTCHA, the hub instance-policy store, MHP federation, > the community relay registry, chat link previews, the TMDB/MusicBrainz > enrichment path, and the node's token-gated loopback control API. > > Method: code reading of `packages/`. The test suite was not run and no live > testing was done against meshbay.org. This is a code and design review, not a > penetration test. Finding numbers are independent of the first two reviews. > > The v5/v6 convention is kept: **a claim names the adversary it holds against.** > The adversaries referenced below are the ones the project already uses — passive > hub, active hub, malicious node operator, malicious group member, network > attacker, local attacker — plus two the newer features introduce: **any > registered hub user with no group membership**, and **a federated peer hub**. --- ## 1. Executive summary **The critical and high findings from the second review have genuinely been closed, and closed well.** The unified handshake is the right shape: one length-prefixed, domain-separated, role-bound transcript; mandatory channel binding; a mutual proof where the node demonstrates GEK possession over the client's nonce *and* signs the transcript with its long-term key; `scope="user"` enforced by default; `group_id` mandatory. It is now run by **both** the WebRTC and the QUIC transports — the C6 divergence that produced most of the second review is structurally gone (only a stale docstring in `quic_server.py` still says otherwise). C1 (the unauthenticated node HTTP file API) was deleted outright rather than patched. C2, C3, C5a, C5b, H1, H4, H5, H6, H7, M7, M8 are all addressed in the code, and the invite rewrite closed H3/M3. Device linking, the password split, Argon2id-256 MB on the hub verifier, refresh-token family rotation, email-at-rest encryption, and session/device teardown on passphrase change are all present and correct. **What this review finds is a second generation of the same pattern:** new surface was added faster than the authorization model was extended to cover it, and a few of the second-review fixes did not reach every path. - The **QUIC transport** got the new handshake but not the new *chat* rules: `_do_chat_message_sync` still takes `sender_id` from the wire (NS6) and broadcasts through a connection-global peer registry regardless of group (H1), and still runs a 30-second synchronous `ffmpeg` on the event loop with no concurrency cap (H6). The WebRTC path fixed all three. - The **hub moderation surface** had a privilege-escalation hole: a *moderator* could promote any other account to *admin* (`PATCH /v1/admin/users/{id}` was gated by `require_moderator` but wrote `role`). **Fixed 2026-09-01.** - **`POST /v1/reports`** was unauthenticated, unthrottled, and auto-blocked a content hash after **two** reports — a network-wide censorship/DoS primitive for anyone who learns a public file's blake3 id. **Fixed 2026-09-01** (auth, rate limit, distinct-reporter counting, refused when public groups are off). - The **registration reCAPTCHA** was inert: the server only checked it when `auth_key` was absent, and the real web client always sends `auth_key`, so a bot skipped it by including that field. **Fixed 2026-09-01** — gate is now unconditional when a captcha is configured; the desktop client renders the widget too. - **Chat link previews** are a real SSRF surface (correctly identified as such in the module) but the gate has gaps: no per-member rate limit, no port restriction, and DNS rebinding is left as a documented residual. - **MHP federation** trusts any registered peer hub to push directory rows and revocations, never checks the token audience, and the revocation-propagation path is a silent no-op because nodes verify only against their own hub's key. - There is still **no CSP or security-header policy** on the hub-served SPA (second review L5), which matters more now that the SPA renders third-party OpenGraph images and metadata. None of this breaks the architecture. The cryptographic core and the trust model are unchanged and still sound. H1, H2, M1 and M6 were fixed on 2026-09-01; the QUIC chat path (M2) should be treated as blocking for any deployment where native/QUIC peers share a node, and the link-preview SSRF (M3) should be bounded before that feature runs on an internet-facing node. What remains on the hub (M4 federation, M5 headers, the L-list) is hardening, not a hole. --- ## 2. What is solid (the delta since the second review) Worth recording, because the remediation was substantial and mostly correct: 1. **`meshbay_common/handshake.py`** — one implementation, called by `webrtc_server.py` and `quic_server.py`. `handshake_transcript()` is length-prefixed and domain-separated (`meshbay:mnp:handshake:v1`), the role is bound so a client proof can never be replayed as a node proof, and `make_proof()` **raises** on an empty channel binding instead of degrading to nonce-only (L4). `authorize_token()` enforces `scope == "user"` by default (M9), requires `group_id` (M1), checks the denylist, the `groups` claim and `hosted_groups`. 2. **Mutual authentication (C3).** `_complete_handshake` returns `HMAC(GEK, node-transcript)` over the client's nonce **and** `Ed25519(sk_node)` over the same transcript; `transport.js` verifies both (`verifyNodeSignature`), refuses a bare `handshake_ack`, and TOFU-pins `node_pk` in `localStorage` with an explicit change warning (`_checkNodePin`). 3. **C1 deleted.** The per-group HTTP file API is gone from `daemon.py` (step 9 is now a comment explaining why). Every client path goes through the MNP handshake. 4. **C2 closed.** `_authorize_node_ws` resolves the node against the DB, checks `scope == "node"`, checks `node.user_id == token.sub`, derives `group_ids` from `GroupMember`, and refuses to displace a live registration. 5. **C5a closed.** Uploads: `SAFE_UPLOAD_NAME` allowlist, `_free_name()` no-overwrite, `MAX_UPLOAD_BYTES` cap, strict chunk ordering, a quarantine subdirectory, and an operator-signed `OP_MEMBER_UPLOAD` kill switch enforced by the node (`_do_file_upload`), not by hiding a button. 6. **C5b closed.** `gek_bundle_store` is deleted; `gek_rotate` is an operator-signed op where the node generates the key with its own CSPRNG (`_admin_exec_gek_rotate` → `ops.set_gek(rotate=True)`). 7. **H1 (WebRTC) closed.** `_do_chat_message` / `_do_chat_history` read `self._group_ctx().get("chat_store")`, `_peer_registry()` is per-group, and `sender_id` is forced to `self._user_id`. 8. **H4 closed.** `Denylist` persists to `denylist.json`; `on_revocation` handles `user`/`group`/`jti`, and `group` also drops live sessions (`_drop_group_sessions`); `webrtc_offer` refuses when the shared group is not `active`. 9. **H5 closed.** `adminop.admin_transcript()` — domain-separated, names the operation, subject, node key, group, nonce and timestamp; `ADMIN_CHALLENGE_TTL` 120 s; verified against `roster.operator_pks()` rebuilt from node state, never from the response. 10. **H6 (WebRTC) closed.** 64 KB pre-handshake buffer, a transcode semaphore, per-user pending-offer caps and a membership check in `signaling.py`, `notify_incoming` requires `peer_ip == caller_ip`. 11. **H7 closed.** The swarm route is mounted correctly, nodes filter by visibility, and `GET /v1/swarm/{hash}` requires auth. 12. **M8 closed.** `announce_node` requires a signed proof of possession. 13. **Device linking** (`_do_device_add`, `_verify_device_signer`): a new device is admitted only by a signature from a **live pinned device of the same account**; the one-time code never reaches the node (it lists candidate hashes and the approver recomputes the match); requests are single-use and capped by `MAX_DEVICES_PER_USER`. The hub holds no user keys and so cannot countersign — this holds against an active hub. 14. **Account lifecycle** (`docs/auth-confirm.md`): passphrase change and reset both revoke every refresh token; reset also deletes every `UserDevice` so a stored device key cannot sign back in past the reset. The recovery key is a pure client-side pass-through — never stored, never logged. --- ## 3. High findings ### H1 — A moderator can promote any account to admin (privilege escalation) > **Fixed 2026-09-01.** `admin_patch_user` now splits authorization by field: > `status` between `active`/`suspended` stays at `require_moderator`; setting > `role`, setting `status = "revoked"`, and touching an admin's account at all > require `user_is_admin(current_user)` (new helper in `deps.py`). Regression > test: `test_moderator_cannot_change_roles_or_revoke`. **Location:** `api/admin.py:185-241` (`admin_patch_user`), `api/deps.py:75-92` `PATCH /v1/admin/users/{user_id}` depends on `require_moderator`, but its body accepts `role`, and the handler writes it with no check that the caller is an admin: ```python if body.role is not None: if body.role not in ("user", "moderator", "admin"): raise HTTPException(status_code=422, ...) user.role = body.role # ← moderator can set "admin" ``` The only guard is `user.id == current_user.id` ("Cannot modify your own account"). So a moderator cannot self-promote directly, but can: - promote a second account they control, or an accomplice, to `admin`; - **demote existing admins** to `user`, or set their `status` to `revoked`. `admin` is the real instance boundary: `admin_patch_settings` (public-groups switch), `admin_delete_user` (irreversible erasure), `admin_revoke` (user/group revocation broadcast to every node), `register_peer`, `admin_add_blocklist`. A moderator reaching `admin` reaches all of it. **Impact.** Full instance takeover from the moderator role. Moderator is meant to be a content-moderation role (suspend/revoke groups, read logs), not an administrative one — `admin_delete_user`'s own docstring draws exactly that line ("Admin rather than moderator: suspension is reversible … this is not"). **Fix.** Split the handler: `status` changes among `active`/`suspended` stay at `require_moderator`; `role` changes and `status = "revoked"` require `require_admin`. Also forbid granting a role higher than the caller's, and forbid demoting an equal-or-higher role. --- ### H2 — Unauthenticated, unthrottled, permanent global content blocklisting > **Fixed 2026-09-01.** `POST /v1/reports` now requires a signed-in account > (`get_current_user`), is rate-limited (`10/hour`), counts **distinct reporting > accounts** (one vote per account per hash via `reporter_id`), and is refused > outright (`403`) when the hub has public groups switched off — a private-only > hub brokers no public content and nothing syncs the blocklist, so an open write > endpoint there is pure abuse surface. `AUTO_BLOCK_THRESHOLD` raised 2 → 3. > Tests rewritten in `test_moderation.py`. > > Note also confirmed while fixing: **no node currently consumes > `ContentBlocklist`** — `GET /v1/blocklist` exists ("nodes sync on startup") but > nothing fetches it, and `swarm_register` checks the *CSAM* list, not this one. > So the network-wide censorship effect was latent (it activates when node sync > ships); the DB-fill / poisoned-moderation-signal / admin-panel-garbage surface > was live. The auto-block path should stay gated as above when sync lands. **Location:** `api/moderation.py:39,58-103` (`report_content`) `POST /v1/reports` has **no authentication and no rate limit**. It counts *all* existing `ContentReport` rows for a hash — regardless of who filed them or from where — and: ```python AUTO_BLOCK_THRESHOLD = 2 ... if count + 1 >= AUTO_BLOCK_THRESHOLD: ... db.add(ContentBlocklist(content_hash=..., added_by="auto")) ``` So **two unauthenticated HTTP requests** naming the same 64-hex blake3 id add that id to `ContentBlocklist`. Nodes sync the blocklist (`GET /v1/blocklist`, unauthenticated) and `swarm_register` refuses a blocked hash with HTTP 451. Removal is a manual admin action (`DELETE /v1/admin/blocklist/{hash}`). **Impact.** Anyone who learns the blake3 id of a public file — trivially, any group member sees ids in the index; any registered user can probe `GET /v1/swarm/{hash}` — can suppress that file across the whole network with two anonymous requests. It is also a self-inflicted amplifier: one script can block thousands of hashes. `content_hash` is the only validated field (`group_id`, `reason`, `detail` are free-form and rendered in the admin UI). **Fix.** Require authentication on `POST /v1/reports`; dedupe reports by `(content_hash, reporter)` so the threshold means *distinct* reporters; add a rate limit; raise `AUTO_BLOCK_THRESHOLD` and/or make auto-block queue for human review rather than take effect immediately; authenticate `GET /v1/blocklist` and `/v1/blocklist/check` (node scope). --- ## 4. Medium findings ### M1 — The registration CAPTCHA is inert and trivially bypassed > **Fixed 2026-09-01 (Option A).** The server gate is now `if > _cfg.captcha.enabled:` — no `auth_key` carve-out, no client exemption. The web > client (`registerUser` in `keyderive.js`) forwards `captcha.token`, and the > desktop client, being Chromium, renders the same widget from the shared UI > assets. `captcha.reset()` is called on a failed attempt so the single-use > token is refreshed. Tests: `test_register_captcha.py`. > > Consequence to check on the desktop side: the Electron CSP must allow > `https://www.google.com` and `https://www.gstatic.com` for `script-src` / > `frame-src`, or the widget will not render and the (already-disabled) submit > button stays disabled. A headless/CLI `register` has no widget and is the one > path with no human check — which is the path you would want gated anyway; a CLI > can open a browser window for it. **Location:** `api/users.py:130-191` (`register`), `static/keyderive.js:279-303` (`registerUser`), `static/auth-page.js:229-256` Server side: ```python # Captcha gate — web path only (native clients send auth_key) if _cfg and _cfg.captcha.enabled and not body.auth_key: await _verify_captcha_or_raise(body.captcha_token, request) ``` The CAPTCHA is checked **only when `auth_key` is absent**. But the real web client's registration path (`window.MeshBayKeys` present, which is always) calls `registerUser()`, which sends `{ username, email, auth_key }` and **no `captcha_token`** at all. The branch that sends `captcha_token` (`auth-page.js:248`) is a dead `else` for a client without `MeshBayKeys`. So: a human filling the Register form solves a reCAPTCHA whose token is never transmitted and never checked, and a bot registers accounts at will by including any `auth_key`-shaped string. `@limiter.limit("5/minute")` is the only remaining brake (and see L10 for why that may also be weak). Password reset is unaffected — `password_reset_request` checks the CAPTCHA unconditionally when enabled. **Fix.** Gate on `_cfg.captcha.enabled` alone (drop `and not body.auth_key`), and have `registerUser()` include `captcha_token`. If native clients genuinely cannot present one, gate on the *client type* explicitly (a header or a scope), not on the presence of a field any caller can supply. --- ### M2 — QUIC transport: chat sender spoofing, cross-group broadcast, and a blocking ffmpeg **Location:** `transport/quic_server.py:220-247, 402-463, 509-525` The QUIC server is started in production (`daemon.py:540`, `host="::"`, default port 19000, `groups=groups_ctx`). It got the new unified handshake — and the GEK proof *is* implemented in `_do_handshake_response_sync`, so the docstring at `quic_server.py:259-263` ("NOT YET DONE — finding C6 remains open on this transport") is simply stale. But the chat and streaming handlers were never brought up to the WebRTC path's rules: **M2a — `sender_id` is taken from the wire (NS6 regression).** ```python asyncio.ensure_future(chat_store.save_message( sender_id=msg.get("sender_id", self._user_id), ...)) ... broadcast = { ... "sender_id": msg.get("sender_id", self._user_id), ... } ``` An authenticated QUIC peer can post chat as any `sender_id`. The WebRTC path forces `sender_id=self._user_id` (`webrtc_server.py:3493,3504`). **M2b — the peer registry is connection-global, not per-group (H1 regression).** `_do_chat_message_sync` broadcasts to `self._ctx.get("_peers", {})`, which is a single dict on the `QuicChunkServer` instance shared across every group. A member of group A, connected over QUIC, has their (spoofable) message fanned out to QUIC peers of every other group on the node. (`chat_store` is never set in the QUIC ctx, so messages are dropped rather than persisted — but still broadcast.) **M2c — synchronous ffmpeg on the event loop, no concurrency cap (H6 regression).** `_do_stream_segment_sync` → `_extract_segment` runs `subprocess.run([... "ffmpeg" ...], timeout=30)` directly inside `quic_event_received`. One request blocks the whole node for up to 30 s; there is no transcode semaphore. `_do_file_request_sync` likewise does blocking file I/O in the loop. **Impact.** Limited to peers reachable over QUIC/UDP 19000 — LAN, a port-forwarded node, or hub-less `group://` — and to native clients (browsers use WebRTC, a separate registry). Still: chat impersonation and cross-group leakage against exactly the "malicious group member" adversary the WebRTC fixes were written for, plus a one-request node stall. **Fix.** Route the QUIC chat and stream handlers through the same per-group context and `sender_id`-from-session logic as WebRTC (ideally shared helpers in `meshbay_common`, the same move that fixed C6); make `_extract_segment` async and put it behind the transcode semaphore, or disable the QUIC `STREAM_SEGMENT` handler until it is at parity. Update the stale docstring. --- ### M3 — Chat link previews: SSRF gate has no rate limit, no port restriction, and a known rebinding hole **Location:** `node/linkpreview.py`, `webrtc_server.py:3588-3636` (`_do_link_preview_request`) The design is right — the *node* fetches, not the browser or the hub — and `safe_url()` blocks non-http(s) schemes, embedded credentials, and any resolved address that is not globally routable, re-checking every redirect hop by hand. But: 1. **No rate limit / no per-member cap.** `_do_link_preview_request` is reachable by any group member after the handshake, and the in-memory cache (`_LINK_PREVIEW_MAX = 256`, TTL 1 h) only dedupes exact repeats. A member pasting many distinct URLs drives unbounded outbound HTTP from the operator's machine — an amplification/DoS vector and a way to disclose the operator's IP to arbitrary hosts on demand. 2. **Port is not restricted.** `safe_url()` validates the scheme and the resolved IP but passes `parts.port` straight through. A member can point the node at `http://:` — third-party port scanning from the operator's address, and reaching services that are internet-routable but firewalled to the node's network. 3. **DNS rebinding.** `safe_url()` resolves and checks the address, then `httpx.get()` resolves again at connect time. The module documents this as a deferred residual ("closed properly by pinning the checked IP"). Until the pin lands, a name that answers public on check and internal on connect is a way in. 4. **Image decode.** `fetch_image` → `_downscale` opens attacker-supplied bytes with Pillow; `Image.open` + `thumbnail` after a full decode. Pillow's default decompression-bomb guard applies, but a 2 MB input is allowed and the guard is the only ceiling. **Fix.** Add a per-connection and per-node rate limit on `LINK_PREVIEW_REQ` (and the same for `MEDIA_META_REQ` / `TMDB_SEARCH_REQ`); restrict the port to 80/443; pin the checked IP for the actual connection (resolve once, connect to the literal, send `Host:`); set `PIL.Image.MAX_IMAGE_PIXELS` low and cap decoded dimensions before `thumbnail`. --- ### M4 — MHP federation: peer hubs are over-trusted, token audience is unchecked, revocation propagation is a no-op **Location:** `api/federation.py:59-190`, `api/revocation.py:66-80` (node `verify_and_apply`), `node/daemon.py:575-596` (`on_revocation`) 1. **Audience never verified.** `_verify_mhp_token` accepts an optional `expected_aud` but **no caller passes it** — `export_directory`, `receive_directory` and `receive_revocation` all call `_verify_mhp_token(token, db)`. `_issue_mhp_token` sets `aud = target_hub_id`, so the check is available and deliberately unused. A token hub B minted for hub C (valid 300 s) is replayable at any other hub that has B registered as a peer. 2. **Any registered peer can inject the directory.** `receive_directory` iterates an unbounded `body.groups` list with peer-chosen `id` and `name`, upserting `FederatedGroup` rows. No cap, no validation. `name` is rendered in the SPA Explore view; `id` is peer-chosen and shares the UUID space with local groups. 3. **Revocation propagation does nothing on nodes.** `receive_revocation` passes the peer's token straight to `broadcast_revocation`, which forwards it to local nodes. Nodes verify a revocation token against **their own hub's public key** (`session.hub_pk_pem`), so a peer-signed token fails `jwt.decode(...)` and is dropped with a warning. The federated `/mhp/revoke` path therefore silently accomplishes nothing — false assurance that "revocations propagate" across a federation. **Impact.** A malicious or compromised peer hub can flood/poison the local public directory and cannot be relied on to actually revoke anything. Cross-hub token replay within a federation. All of this is bounded by the admin having explicitly run `POST /mhp/peers` — federation is opt-in and manual — so the adversary is "a peer the admin chose to trust", which is exactly the adversary MHP's own auth is supposed to constrain. **Fix.** Pass `expected_aud=_hub_id` in every `_verify_mhp_token` call; cap `body.groups` and validate each row; namespace `FederatedGroup.id` or refuse an `id` that collides with a local group; for revocation, either re-sign accepted peer revocations with the local hub key before broadcasting (with a policy on which peers may revoke which targets) or drop the endpoint and document that revocation does not federate. --- ### M5 — No CSP or security headers on the hub-served SPA (second review L5, still open) **Location:** `api/webapp.py:80-123`, `app.py:160-214` The SPA shell is returned with only `Cache-Control: no-store`. There is no `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options` / `frame-ancestors`, and no Subresource Integrity on the scripts loaded from `/a//` (including the vendored `argon2.min.js`). The hub sets no CORS middleware (correct) but also no protective headers at all. For an application whose threat model explicitly includes "the hub could inject JS" (T3) and which now renders third-party OpenGraph images and TMDB/MusicBrainz metadata inside the group UI, a strict CSP (`default-src 'none'`, an explicit `connect-src`/`img-src`, `frame-ancestors 'none'`, `base-uri 'none'`) plus SRI is the cheap mitigation that makes a *silent* injection harder and gives a browser extension something to pin against. The node's loopback API already sets exactly this kind of header block (`ui/app.py:100-117`); the hub does not. **Fix.** Add a response-header middleware on the hub with a strict CSP for the SPA routes and `nosniff`/`Referrer-Policy`/`frame-ancestors` globally; add SRI hashes to the `