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 | |
| 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
27 files changed, 1818 insertions, 181 deletions
diff --git a/docs/third-review.md b/docs/third-review.md new file mode 100644 index 0000000..531f73e --- /dev/null +++ b/docs/third-review.md @@ -0,0 +1,713 @@ +# 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. 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` took `sender_id` from the wire (NS6), broadcast + through a connection-global peer registry regardless of group (H1), and ran a + 30-second synchronous `ffmpeg` on the event loop with no concurrency cap (H6). + **Fixed 2026-09-01** — handlers brought to WebRTC parity, and the QUIC + listener is now off by default (`[node] quic_enabled`) since nothing ships a + QUIC client. +- 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 had gaps: no per-member rate limit, no port + restriction, and DNS rebinding a documented residual. **Fixed 2026-09-01** + (rate limit, port allowlist, connect-address re-check, bomb guard). +- **MHP federation** trusted any registered peer hub to push directory rows and + revocations, never checked the token audience, and the revocation-propagation + path was a silent no-op. **Fixed 2026-09-01** (source bound to the signer, + push capped, revocation acts on the peer's own directory entries, replay + rejected). +- There was **no CSP or security-header policy** on the hub-served SPA (second + review L5). **Fixed 2026-09-01** — a middleware applies the same policy the + desktop client already enforces on these files. Wants a pass against the + running SPA. + +None of this breaks the architecture. The cryptographic core and the trust model +are unchanged and still sound. H1, H2 and M1–M5 were +fixed on 2026-09-01; what is left is the L-list — opportunistic hardening, not a +hole — plus verifying the SPA CSP (M5) against the live app. + +--- + +## 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 + +> **Fixed 2026-09-01, two ways.** +> 1. **Exposure removed:** the QUIC listener is now off by default — +> `[node] quic_enabled = false` (`MESHBAY_QUIC_ENABLED` overrides), gated in +> `daemon.py`. Nothing ships a QUIC client, so a node started none for no one. +> 2. **Handlers brought to parity** anyway, for when a client does exist: +> `_do_chat_message_sync` forces `sender_id` from the authenticated session +> (M2a), resolves `chat_store` and the peer set per group via `_group_ctx()` / +> `_peer_registry()` (M2b, kept separate from the WebRTC peer set), and +> `STREAM_SEGMENT` extraction runs in a thread behind a small semaphore as a +> tracked task (M2c). Stale C6 docstring corrected. +> +> Original finding text kept below for the record. + +**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 + +> **Fixed 2026-09-01.** +> - **Rate limit:** `_do_link_preview_request` is now bounded per connection (15) +> and node-wide (60) over a 60 s window; a cache hit is free, and over the +> ceiling the reply is a plain `ok: false` (bare link), not cached. +> - **Port allowlist:** `safe_url()` restricts the port to `{80, 443, 8080, +> 8443}` — every real OpenGraph page, none of the SSH/mail/DB/cache/search +> ports. Deliberately not just 80/443, to keep legitimate sites on alt-HTTP +> working. +> - **DNS rebinding:** the connection's actual peer address is re-checked +> against the public-address rule (`_reject_if_rebound`) before the body is +> read. Best-effort — a full literal-pin with cert-for-name is noted as +> remaining hardening. +> - **Decompression bomb:** `_downscale` refuses an image whose header +> dimensions exceed ~40 MP before decode. +> +> Not extended to `MEDIA_META_REQ` / `TMDB_SEARCH_REQ`: those reach a fixed +> host, so they carry a quota concern but not an SSRF one — left for a separate +> pass. + +**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://<globally-routable-ip>:<any-port>` — 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 + +> **Fixed 2026-09-01.** +> - `receive_directory` binds `source_hub` to the token's verified `iss`, so a +> peer cannot relay or spoof a third hub's groups; the push is capped +> (500/request, 2000/peer), rows are type/length-checked, and a federated id +> that collides with a local group is refused. +> - `receive_revocation` no longer forwards a foreign-signed token to local nodes +> (the no-op). It verifies the inner token against the sending peer's key and, +> for `target == "group"`, prunes our copy of that peer's directory entry — a +> peer cannot revoke our users or a group it did not advertise. +> - `POST /mhp/directory` and `/mhp/revoke` reject a replayed `jti` within the +> token TTL. Audience binding is still unavailable (the sending side that would +> set `aud` is unbuilt); the replay check covers that concern for now. Tests in +> `test_federation.py`. + +**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) + +> **Fixed 2026-09-01.** A middleware in `create_app` adds `Content-Security-Policy`, +> `X-Content-Type-Options: nosniff`, `Referrer-Policy` and `X-Frame-Options: DENY` +> to every response. `webapp.CSP` is the same policy the desktop client already +> enforces on these exact UI files (`default-src 'none'`, `script-src 'self' +> 'wasm-unsafe-eval' <recaptcha>` — the hub origin is not a script source, +> `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`), plus the +> reCAPTCHA hosts. The shell's dead `window.__MB_ASSET_V` inline script is +> removed so no inline `'unsafe-inline'`/nonce is needed for scripts. **Wants a +> pass against the running SPA** — a mis-tuned CSP shows as a blank page — but it +> matches a policy already proven with these files under Electron. SRI on the +> `/a/<hash>/` scripts is still not done (same-origin, so lower value than the +> CSP). Tests in `test_security_headers.py`. + +**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/<hash>/` (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 `<script>` tags in `_HTML` (the content hash is already computed). + +--- + +### M6 — `add_group_member` accepts node-scoped tokens — WITHDRAWN + +> **Not a finding. Fixed then reverted 2026-09-01.** +> +> `add_group_member` accepting a node-scoped token is **deliberate** (commit +> `0443cf8`): the node calls `POST /v1/groups/{id}/members/{username}` after a +> CLI `member invite` so the group becomes visible in the invitee's SPA, and it +> authenticates with a node-scoped token. The `group.admin_id == caller` check +> is the real guard — a node can only touch its own operator's groups, adding an +> already-registered account. +> +> This review misread that as drift (a stale test, +> `test_node_scope_blocks_add_member`, asserted the opposite and had been left +> red on `main`). Tightening the dependency to `require_user_scope` **broke the +> CLI invite → hub-membership flow**: `ops.create_invite` swallows the resulting +> 403 with a `log.warning`, so an invited user silently never appears in +> `group_members` and the group is invisible to them. Found in live testing. +> The dependency is back on `get_current_user` and the stale test now asserts +> the intended behaviour (node token may add to its own group, 403 for a group +> it does not own). + +The original M6 concern — a stolen node token adding accounts to the operator's +own groups — is real but low: bounded to the operator's groups, existing +accounts only, and it is now a *required* capability. If it is ever worth +constraining, it needs a dedicated node→membership path, not a scope block on +this shared endpoint. + +--- + +## 5. Low findings / notes + +- **L1 — Community relay registration is unauthenticated and has no proof of + possession.** `api/relay.py:57-78` — `relay_register` only compares the + submitted `pk_relay` against the admin-approved value (both effectively + public); there is no signature over anything. Anyone who knows an approved + `relay_id` + `pk_relay` can repoint that relay's `endpoint`. `GET /v1/relays` + is unauthenticated. `_relays` is in-memory (lost on restart). Relay traffic is + E2E-encrypted, so the impact is redirection / forced-relay / DoS rather than + disclosure — but a registry write should require the relay to sign a fresh + challenge with `sk_relay`, on the `announce_node` pattern. + +- **L2 — Two orphaned modules invite the wrong wiring later.** + `node/replication.py` (`ContentReplicator`) fetches `{endpoint}/index` and + `{endpoint}/file/{id}` — the HTTP API removed by the C1 fix — and joins an + attacker-controlled `file_name` directly onto a local path + (`self._local_dir / file_name`, path traversal) with an unbounded + `read_bytes()`. `node/revocation.py` (`RevocationSubscriber`) is superseded by + `hub_client` + `daemon.on_revocation` and keeps a weaker in-memory-only model + with `verify_exp=False`. Neither is imported by the daemon. Delete both, or + fold `replication.py` into the MNP path with proper name sanitisation before + it is revived. + +- **L3 — Account / email enumeration is wider than before.** `register` and + `PATCH /me` return `409 "Email already in use"`; `register` returns + `409 "Username already taken"`; `GET /v1/users/{username}/pubkeys` returns 404 + vs 200. Together these enumerate which usernames and which email addresses have + accounts. `login`, `device_auth` and `password_reset_request` are correctly + uniform — the leak is on the account-management endpoints. Decide whether this + is acceptable by design (public identities) and document it; if not, make the + responses uniform and move the "email in use" signal into the verification + email instead. + +- **L4 — No aggregate upload quota.** `MAX_UPLOAD_BYTES` caps a single file at + 4 GB (C5a), but there is no per-user or per-group total. A member — or an + active hub minting tokens for many synthetic accounts — can still fill the + operator's disk one 4 GB file at a time. Second review H6 asked for quotas; + only the per-file cap landed. + +- **L5 — Node control-API token accepted in the query string.** + `ui/app.py:92-98` accepts `?t=<token>` as well as the `X-MeshBay-Token` + header. Tokens in URLs end up in access logs and process listings. The + loopback bind + `0600` token file already blunt this, and DNS-rebinding is + covered (a rebound page cannot read the on-disk token) — but header-only would + be cleaner. + +- **L6 — Revocation tokens never expire.** `_sign_revocation` sets no `exp`; + both `on_revocation` and the dead `RevocationSubscriber` decode with + `verify_exp=False`. This is fine for "deny forever" semantics, but a captured + revocation token is replayable indefinitely, there is no positive un-revoke + signal, and clearing is a manual operator action (`denylist clear`). At least + bind a `revoked_at` freshness window on replay, or a monotonic sequence per + hub. + +- **L7 — The CSAM check is exact-hash only.** `csam.py` compares blake3 hex + against a (shipped-empty) newline list; production CSAM databases are + perceptual (PhotoDNA). `check_content_hash` is called from `swarm_register` + only. The control is structural, not yet functional — worth stating in the + docs so it is not relied on operationally. + +- **L8 — Group names are unvalidated.** `create_group` trims the name but + enforces no length or character set (only per-owner uniqueness). The name + flows into emails (`send_invite_notification`), notifications, and the SPA. + Python's `email` package will reject control characters at send time, so header + injection is contained, but a length/charset check at creation is cheap. + +- **L9 — `decode_access_token` / `authorize_token` do not `require=["exp"]` or + check `iss`/`aud`.** Every token the hub issues carries `exp`, so this is + latent — but a future path that mints a token without `exp` would produce a + non-expiring JWT accepted on every transport and every hub endpoint. Pass + `options={"require": ["exp"], "verify_aud": ...}` and bind an audience for the + node vs. user token split. + +- **L10 — Rate-limit keying depends on unpinned proxy-header handling.** + `middleware.py` uses `slowapi`'s `get_remote_address`, which returns + `request.client.host`. Behind the loopback Caddy proxy this is the real client + IP **only** if uvicorn's `ProxyHeadersMiddleware` is active and Caddy sets + `X-Forwarded-For`. `daemon.py` calls `uvicorn.run()` without an explicit + `proxy_headers` / `forwarded_allow_ips`, so this relies entirely on uvicorn's + defaults and the Caddy config, neither pinned in the repo. If it regresses, + every `@limiter.limit` collapses to a single global bucket keyed on + `127.0.0.1` — no per-client throttling, and one abuser starves everyone. Use + the `netutil.client_ip` helper (the M7 fix) as the limiter's `key_func` so the + behaviour is defined in one place. + +- **L11 — `client_diag` and `X-Forwarded-For` log hygiene.** `client_diag` is + correctly truncated/stringified before logging. `netutil.client_ip` correctly + takes the rightmost hop behind a trusted proxy. No action — noting that the + M7 pattern is applied consistently *except* by the rate limiter (L10). + +--- + +## 6. Does the system do what it claims? (2026-09-01) + +Against the v6 §4 claims, updated for this review: + +| Claim | Passive hub | Active hub | Malicious member | Notes | +|---|---|---|---|---| +| Data never transits the hub | ✅ | ✅ | — | WebRTC/QUIC P2P; hub relays SDP only | +| Hub stores no content/index/chat | ✅ | ✅ | — | Confirmed in schema; `hub_settings` is instance policy, not group content | +| File content unreadable by the hub | ✅ | ✅ for content | — | GEK never reaches the hub; invite rewrite closed H3 | +| Node operator is sole content authority | ✅ | ✅ | ✅ since 2026-09-01 — QUIC chat/stream handlers brought to WebRTC parity, and the QUIC listener is off by default (was M2) | +| Mutual node authentication | ✅ | ✅ | ✅ | New handshake + `transport.js` pin — a real improvement | +| Immediate revocation | ✅ | ✅ locally | — | Persisted denylist, group targets handled. Federation prunes the peer's directory entry (was M4); it does not reach nodes, and nothing local hosts a federated group | +| Suspending/revoking a group blocks connections | ✅ | ✅ | — | `webrtc_offer` checks status; node drops sessions on `revoke` | +| Device linking safe against the hub | ✅ | ✅ | ⚠️ browser link inherits T3 (documented) | Countersignature by a pinned device; hub holds no user keys | +| Chat authenticated between members | ❌ not yet | ❌ | ❌ | Sender Keys is Phase 15; today chat is node-asserted on every transport (M2a's wire-asserted QUIC path was closed 2026-09-01) | +| Node does not emit traffic on a member's behalf | — | — | ✅ since 2026-09-01 — link previews rate-limited, ports restricted, connect-address re-checked (was M3) | +| Hub cannot be used to censor content | — | — | ✅ since 2026-09-01 — `POST /v1/reports` needs auth, distinct reporters, public groups on (was H2) | +| Moderator ≠ administrator | ✅ | — | — | ✅ since 2026-09-01 — `admin_patch_user` split by field (was H1) | + +**One-sentence version:** *the E2E story between browser and node is now +genuinely mutual and covers every path, the second review's critical gaps are +closed, and H1/H2/M1–M5 were fixed the day this was written (M6 was withdrawn as +a misread of a deliberate design) — leaving the L-list as opportunistic +hardening and one thing to verify: the SPA's new CSP against the live app.* + +--- + +## 7. Prioritised action plan + +| # | Finding | Severity | Effort | When | +|---|---|---|---|---| +| H1 | Moderator can write `role` → admin | High | S | ✅ **fixed 2026-09-01** — handler split by field | +| H2 | Unauthenticated 2-report global blocklist | High | S | ✅ **fixed 2026-09-01** — auth + distinct-reporter + rate limit + public-groups gate | +| M1 | Registration CAPTCHA inert | Medium | S | ✅ **fixed 2026-09-01** — gate unconditional; desktop renders the widget | +| M2 | QUIC chat: `sender_id` spoof, cross-group broadcast, sync ffmpeg | Medium | M | ✅ **fixed 2026-09-01** — handlers at WebRTC parity + `quic_enabled` off by default | +| M3 | Link-preview SSRF: no rate limit, ports open, rebinding | Medium | M | ✅ **fixed 2026-09-01** — rate limit + port allowlist + connect-address re-check + bomb guard | +| M4 | Federation: peer over-trust, `aud` unchecked, revoke no-op | Medium | M | ✅ **fixed 2026-09-01** — source bound to signer, push capped, revoke prunes the peer's own entries, replay rejected | +| M5 | No CSP / security headers on the SPA | Medium | S | ✅ **fixed 2026-09-01** — CSP + `nosniff` + `frame-ancestors` middleware; verify against the live SPA | +| M6 | `add_group_member` accepts node tokens | — | — | ⛔ **withdrawn** — deliberate (CLI invite flow, commit 0443cf8); the "fix" broke it and was reverted | +| L1 | Relay registration no PoP | Low | S | If/when the relay registry is used | +| L2 | Orphaned `replication.py` / `revocation.py` | Low | S | Delete now | +| L3–L11 | See §5 | Low | S | Opportunistic | + +Two structural recommendations, both echoing the second review: + +1. **Make transport parity a test, not a habit — again.** C6 was fixed by moving + the handshake into `meshbay_common`; the *chat* and *stream* handlers were + not moved, and M2 was the result. The 2026-09-01 fix mirrored the WebRTC + logic into `quic_server.py` by hand — the durable version is shared helpers + in `meshbay_common` (`sender_id` enforcement, per-group context, the + transcode gate) with a test that fails if a transport calls a chat/stream + path that bypasses them. + +2. **Every new outbound or cross-trust surface needs a rate limit and an + adversary named in the same commit.** Link previews, TMDB/MusicBrainz search, + `POST /v1/reports`, `receive_directory` — each added a way for a low-privilege + party (a member, an anonymous caller, a peer hub) to make the node or hub do + work or accept state, and each shipped without a bound on how much. The + 2026-09-01 fixes added the bounds to reports and link previews; + `MEDIA_META_REQ` / `TMDB_SEARCH_REQ` still want a quota, and + `receive_directory` a cap. + +--- + +## 8. Conclusion + +The architecture is unchanged and still correct, and the remediation since +2026-08-13 was real: the unified handshake, the deletion of the node HTTP API, +mutual node authentication with client-side pinning, the invite/pairing rewrite, +device linking, and the account-recovery design are all solid security +engineering, and most of the second review's C- and H-list is genuinely closed. + +The new findings are narrower and more uniform in shape than last time: a role +check that grants too much, an anti-abuse endpoint with no abuse protection, a +CAPTCHA wired to a condition the real client never meets, a transport that +received the new authentication but not the new authorization, an over-trusted +federation peer, and a missing header policy. None of them required exotic +capability, and none of them were architectural — they were the cost of adding +six subsystems faster than the authorization model grew to cover them. + +H1, H2 and M1–M5 were fixed the day this was written; M6 was withdrawn — it +misread the node registering a hub membership during the CLI invite flow +(deliberate, commit `0443cf8`) as authorization drift, and the "fix" broke that +flow in live testing. What is left is the L-list — opportunistic hardening — and +one verification: the SPA's new CSP (M5) against the running app, since a +mis-tuned CSP shows as a blank page. On a build whose honest claims are now +strong and largely +defensible. diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 3926c01..e071074 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -58,15 +58,29 @@ const SCHEME = 'app'; // The hub is reachable under connect-src, for its API and its signaling socket. // It is deliberately absent from script-src: nothing it returns is executed, // which is the whole reason this application exists (T3). +// +// The one exception is reCAPTCHA, used to gate sign-up (and password reset) the +// same way it gates them in the browser. Its script comes from www.google.com, +// its challenge is a www.google.com iframe, and its assets sit on +// www.gstatic.com. These two hosts — and only these two — are allowed under +// `script-src`, `frame-src` and `img-src` for that purpose. It is a real, if +// small, dent in "no third-party code runs here": Google's reCAPTCHA script +// executes in the renderer. It is accepted deliberately so a native sign-up is +// gated like a web one without asking the user to do anything extra, and it is +// the *same* dependency the hub-served SPA already carries. If sign-up ever +// moves to a proof-of-work challenge, delete RECAPTCHA_SRC and the three +// directives that spread it, and the widget in auth-page.js with them. +const RECAPTCHA_SRC = 'https://www.google.com https://www.gstatic.com'; const CSP = [ "default-src 'none'", - "script-src 'self' 'wasm-unsafe-eval'", + `script-src 'self' 'wasm-unsafe-eval' ${RECAPTCHA_SRC}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", + `img-src 'self' data: blob: ${RECAPTCHA_SRC}`, "media-src 'self' blob:", "font-src 'self'", "connect-src 'self' https: wss:", "worker-src 'self'", + `frame-src ${RECAPTCHA_SRC}`, "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", @@ -857,6 +871,7 @@ function registerBridge() { `username = "${username}"`, '', '[node]', + 'quic_enabled = false # QUIC direct path; no client uses it yet', 'quic_port = 19010', 'ui_port = 18000', '', 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}" diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index b1ebd54..78ee873 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -36,7 +36,10 @@ url = "https://meshbay.org" username = "myusername" [node] -quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access +# QUIC (MNP) direct path — LAN, port-forwarded, hub-less. Off by default: no +# client speaks QUIC yet, so leaving it on only opens a UDP port. +quic_enabled = false +quic_port = 19010 ui_port = 18000 # local control API — JSON, 127.0.0.1 only, token-gated # One-time codes. An invitation waits for someone to read their messages; an @@ -129,6 +132,13 @@ class HubConfig: @dataclass class NodeConfig: quic_port: int = 19010 + # The QUIC MNP listener. Off by default: no shipping client speaks QUIC yet + # (the browser and the desktop client use WebRTC; the hub-less `group://` + # sidecar is unbuilt), so starting it only opens a UDP port with nothing to + # reach it. Turn on for LAN / port-forwarded / hub-less direct access once a + # client for it exists. `punch_nat()` is a direct-connection helper, not a + # NAT-traversal stack — a peer behind NAT still needs the port forwarded. + quic_enabled: bool = False ui_port: int = 18000 # How long a one-time code stays usable. Invitations travel through a human # conversation and are answered days later; operator pairing happens during @@ -303,6 +313,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: # `port` (TCP+TLS) and `http_port` no longer exist — both listeners were removed # in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`. cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port) + cfg.node.quic_enabled = bool(nd.get("quic_enabled", cfg.node.quic_enabled)) cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port) cfg.node.invite_ttl_hours = int( nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours)) @@ -371,6 +382,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.hub.username = user if port := os.environ.get("MESHBAY_QUIC_PORT"): cfg.node.quic_port = int(port) + if (qe := os.environ.get("MESHBAY_QUIC_ENABLED")) is not None: + cfg.node.quic_enabled = qe.strip().lower() in ("1", "true", "yes", "on") if streams := os.environ.get("MESHBAY_MAX_CONCURRENT_STREAMS"): cfg.node.max_concurrent_streams = _positive( streams, cfg.node.max_concurrent_streams, diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 056371a..8f9307c 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -537,7 +537,11 @@ class NodeDaemon: log.warning("WebRTC not available (aiortc not installed)") # 7. QUIC chunk server (LAN / port-forwarded / hub-less direct access) - if QUIC_AVAILABLE: + # + # Off unless `[node] quic_enabled = true`: no shipping client speaks + # QUIC (browser and desktop use WebRTC; the `group://` sidecar is + # unbuilt), so starting it by default only exposes a UDP port. + if QUIC_AVAILABLE and self._config.node.quic_enabled: self._quic_server = QuicChunkServer( sk_node=keys.sk_ed25519, hub_pk_pem=session.hub_pk_pem, @@ -553,6 +557,8 @@ class NodeDaemon: await self._quic_server.start() log.info("QUIC server on port %d (%d groups)", self._config.node.quic_port, len(groups_ctx)) + elif QUIC_AVAILABLE: + log.info("QUIC server disabled ([node] quic_enabled = false)") # 8. Hub WebSocket (signaling + revocations + WebRTC offers) async def on_webrtc_offer(sdp, peer_id, ice_candidates): @@ -1708,6 +1714,7 @@ def main() -> None: f'username = "{username}"', "", "[node]", + "quic_enabled = false # QUIC direct path; no client uses it yet", "quic_port = 19010", "ui_port = 18000", "", diff --git a/packages/meshbay-node/src/meshbay_node/linkpreview.py b/packages/meshbay-node/src/meshbay_node/linkpreview.py index 64067d1..b223aea 100644 --- a/packages/meshbay-node/src/meshbay_node/linkpreview.py +++ b/packages/meshbay-node/src/meshbay_node/linkpreview.py @@ -14,12 +14,15 @@ hub: Because the node makes an outbound request to an address a *member* chose, this is an SSRF surface. `safe_url()` is the gate: http(s) only, no -credentials, and the resolved address must be globally routable — no -loopback, private, link-local, multicast or reserved range. Redirects are -followed by hand so every hop is re-checked. Residual: a DNS name that -resolves clean here and to something internal microseconds later at connect -time (rebinding) — narrow, and closed properly by pinning the checked IP, -which is a follow-up. +credentials, the port restricted to the web set, and every resolved address +must be globally routable — no loopback, private, link-local, multicast or +reserved range. Redirects are followed by hand so every hop is re-checked, +and the address the connection actually landed on is re-checked against the +same rule (`_reject_if_rebound`), so a name that resolves clean and then to +something internal (rebinding) does not get its body read. A full pin — +connect to the validated literal, verify the certificate for the name — is +the remaining hardening. How many previews a member can trigger is +rate-limited by the caller (`_do_link_preview_request`). Nothing is stored durably: the caller keeps an in-memory TTL cache and the OG image rides the existing `media_cache` thumb store (same as a poster). @@ -42,9 +45,15 @@ _TIMEOUT = 5.0 _MAX_REDIRECTS = 3 _MAX_HTML_BYTES = 512 * 1024 _MAX_IMAGE_BYTES = 2 * 1024 * 1024 +_MAX_IMAGE_PIXELS = 40_000_000 # ~40 MP; an OG card image is a fraction of this _IMAGE_MAX_DIM = 600 _UA = "MeshBayBot/1.0 (+https://meshbay.org; link preview)" +# Ports a real OpenGraph-bearing page is served on. Everything else — SSH, mail, +# databases, caches, search, admin panels — is refused, so a member cannot aim +# the node at an arbitrary service even on a public host. +_ALLOWED_PORTS = frozenset({80, 443, 8080, 8443}) + class UnsafeURL(ValueError): """The URL points somewhere the node must not fetch from.""" @@ -75,6 +84,12 @@ def safe_url(url: str) -> str: host = parts.hostname if not host: raise UnsafeURL("no host") + try: + port = parts.port + except ValueError: + raise UnsafeURL("bad port") + if port is not None and port not in _ALLOWED_PORTS: + raise UnsafeURL(f"port {port}") # An IP literal is checked directly; a name is resolved and every answer # must be public — a hostname with one public and one 127.0.0.1 record # would otherwise be a way in. @@ -136,12 +151,32 @@ def _first(metas: dict[str, str], *keys: str) -> str | None: return None +def _reject_if_rebound(resp: httpx.Response) -> None: + """ + `safe_url` validated the name's addresses; this checks the one the + connection actually landed on, so a name that resolves clean and then to + something internal (DNS rebinding) does not get its body read. + + Best-effort: the `network_stream` extension is not present on every + transport (a MockTransport in tests has none), and its absence is not a + failure — the pre-check and the per-hop redirect re-check still stand. + """ + try: + stream = resp.extensions.get("network_stream") + addr = stream.get_extra_info("server_addr") if stream else None + except Exception: + return + if addr and not _addr_is_public(str(addr[0])): + raise UnsafeURL(f"connected to non-public address {addr[0]}") + + async def _get(client: httpx.AsyncClient, url: str) -> httpx.Response: """One GET with manual, re-validated redirects.""" current = safe_url(url) for _ in range(_MAX_REDIRECTS + 1): resp = await client.get(current, headers={"User-Agent": _UA}, follow_redirects=False) + _reject_if_rebound(resp) if resp.is_redirect and "location" in resp.headers: current = safe_url(urljoin(current, resp.headers["location"])) continue @@ -242,6 +277,10 @@ def _downscale(raw: bytes) -> bytes | None: return None try: with Image.open(BytesIO(raw)) as im: + # The header is parsed but the pixels are not decoded yet — refuse a + # decompression bomb before convert()/thumbnail() allocate for it. + if im.width * im.height > _MAX_IMAGE_PIXELS: + return None im = im.convert("RGB") im.thumbnail((_IMAGE_MAX_DIM, _IMAGE_MAX_DIM)) out = BytesIO() diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index ce6fe17..30c7daf 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -61,6 +61,15 @@ CHUNK_SIZE = 1024 * 1024 MAX_MSG = 64 * 1024 * 1024 ALPN = ["meshbay-mnp"] +# ffmpeg is spawned per STREAM_SEGMENT request, and `_extract_segment` runs +# `subprocess.run` synchronously — so without a bound, an authenticated peer can +# both fork-bomb the node and block its event loop for up to 30 s per request +# (finding M2c). Extraction now runs in a thread and passes through this +# semaphore. Small on purpose: the QUIC path has no shipping client yet, this is +# parity work with the WebRTC transcode cap. +_MAX_CONCURRENT_SEGMENTS = 4 +_segment_sem = asyncio.Semaphore(_MAX_CONCURRENT_SEGMENTS) + class Denylist: """ @@ -204,6 +213,9 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._nonce_client: bytes = b"" self._gek_challenge: bytes | None = None self._pending = None + # asyncio holds only a weak reference to a bare task, so a spawned + # handler still running can be collected mid-flight. Hold them. + self._tasks: set[asyncio.Task] = set() def quic_event_received(self, event: QuicEvent) -> None: if isinstance(event, StreamDataReceived): @@ -232,7 +244,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): elif mtype == MNP.FILE_REQUEST: self._do_file_request_sync(stream_id, msg) elif mtype == MNP.STREAM_SEGMENT: - self._do_stream_segment_sync(stream_id, msg) + self._spawn(self._do_stream_segment(stream_id, msg)) elif mtype == MNP.CHAT_MESSAGE: self._do_chat_message_sync(stream_id, msg) elif mtype == MNP.PING: @@ -256,11 +268,10 @@ class _MNPServerProtocol(QuicConnectionProtocol): checks could drift from the WebRTC path independently. All of that now comes from meshbay_common.handshake, shared with WebRTC. - NOT YET DONE — finding C6 remains open on this transport: there is still no - GEK proof here, so a forged or stolen token reaches the node and can inject - chat without holding the group key. The challenge/response and mutual node - proof (quic_binding() is written and unit-tested for exactly this) are the - remaining work in 11.5.4/5/6. + The GEK proof is enforced here too: `_do_handshake_response_sync` runs + the same challenge/response and mutual node proof, from the same shared + module, bound to the QUIC certificate hash. Finding C6 is closed on this + transport. """ try: peer = authorize_token( @@ -339,9 +350,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._user_id = peer.user_id self._group_id = peer.group_id - peers = self._ctx.get("_peers") - if peers is not None: - peers[self._user_id] = self + self._peer_registry()[self._user_id] = self transcript = handshake_transcript( ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding) @@ -365,6 +374,20 @@ class _MNPServerProtocol(QuicConnectionProtocol): return self._ctx["groups"][self._group_id] return self._ctx + def _spawn(self, coro) -> None: + task = asyncio.ensure_future(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + def _peer_registry(self) -> dict: + """QUIC peers for THIS connection's group, keyed per group so a message + never crosses into another group on a multi-group node (findings M2b / + H1). Deliberately separate from the WebRTC registry that also lives in + the group context: the two transports' session objects have different + `_send` signatures, and cross-transport chat fan-out is not wired (no + QUIC client ships yet).""" + return self._group_ctx().setdefault("_quic_peers", {}) + def _do_index_sync_sync(self, stream_id: int) -> None: ctx = self._group_ctx() wire = ctx["index"].serialize() @@ -399,61 +422,83 @@ class _MNPServerProtocol(QuicConnectionProtocol): ) self._send(stream_id, chunk_data) - def _do_stream_segment_sync(self, stream_id: int, msg: dict) -> None: - """Extract and serve one HLS segment via ffmpeg.""" - ctx = self._group_ctx() - file_id = msg["file_id"] - segment_index = msg["segment_index"] - segment_duration = msg.get("segment_duration", 4) + async def _do_stream_segment(self, stream_id: int, msg: dict) -> None: + """ + Extract and serve one segment via ffmpeg — off the event loop and behind + a concurrency bound, so one request can neither stall the whole node nor + fork-bomb it (finding M2c). The WebRTC path has had both since Phase 11.5. + """ + try: + ctx = self._group_ctx() + file_id = msg["file_id"] + segment_index = msg["segment_index"] + segment_duration = msg.get("segment_duration", 4) - entry = ctx["index"].get_entry(file_id) - if not entry: - self._send(stream_id, {"type": "error", "detail": "File not found"}) - return + entry = ctx["index"].get_entry(file_id) + if not entry: + self._send(stream_id, {"type": "error", "detail": "File not found"}) + return - file_path = entry_abs_path(ctx["roots"], entry) - if not file_path.exists(): - self._send(stream_id, {"type": "error", "detail": "File not on disk"}) - return + file_path = entry_abs_path(ctx["roots"], entry) + if not file_path.exists(): + self._send(stream_id, {"type": "error", "detail": "File not on disk"}) + return - start_time = segment_index * segment_duration - segment_data = _extract_segment(file_path, start_time, segment_duration) - if segment_data is None: - self._send(stream_id, {"type": "error", "detail": "Segment extraction failed"}) - return + start_time = segment_index * segment_duration + loop = asyncio.get_event_loop() + async with _segment_sem: + segment_data = await loop.run_in_executor( + None, _extract_segment, file_path, start_time, segment_duration) + if segment_data is None: + self._send(stream_id, {"type": "error", "detail": "Segment extraction failed"}) + return - self._send(stream_id, { - "type": MNP.STREAM_SEGMENT, - "v": MNP_VERSION, - "file_id": file_id, - "segment_index": segment_index, - "data_b64": base64.b64encode(segment_data).decode(), - "size": len(segment_data), - }) + self._send(stream_id, { + "type": MNP.STREAM_SEGMENT, + "v": MNP_VERSION, + "file_id": file_id, + "segment_index": segment_index, + "data_b64": base64.b64encode(segment_data).decode(), + "size": len(segment_data), + }) + except Exception as e: + log.error("stream_segment: %s", e) + self._send(stream_id, {"type": "error", "detail": "Segment extraction failed"}) def _do_chat_message_sync(self, stream_id: int, msg: dict) -> None: - """Receive a chat message, store it, and broadcast to other connected peers.""" - chat_store = self._ctx.get("chat_store") + """ + Store a chat message and broadcast it to the rest of THIS group. + + `sender_id` is the authenticated session's, never the wire's — a peer + must not be able to post as someone else (NS6 / finding M2a). The store + and the peer set come from the group context, not a connection-global + one, so a message never crosses into another group on a multi-group node + (findings M2b / H1). The WebRTC path has done both since Phase 11.5. + """ + gctx = self._group_ctx() + payload = msg.get("payload", b"") + if isinstance(payload, str): + payload = payload.encode() + + chat_store = gctx.get("chat_store") if chat_store: - import asyncio - asyncio.ensure_future(chat_store.save_message( - sender_id=msg.get("sender_id", self._user_id), + self._spawn(chat_store.save_message( + sender_id=self._user_id, iteration=msg.get("iteration", 0), - payload=msg.get("payload", b"").encode() if isinstance(msg.get("payload"), str) else msg.get("payload", b""), + payload=payload, thread_id=msg.get("thread_id"), )) - peers = self._ctx.get("_peers", {}) broadcast = { "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, - "sender_id": msg.get("sender_id", self._user_id), + "sender_id": self._user_id, "iteration": msg.get("iteration", 0), "payload": msg.get("payload", ""), "thread_id": msg.get("thread_id"), "group_id": self._group_id or "", } - for uid, proto in peers.items(): + for uid, proto in list(self._peer_registry().items()): if uid != self._user_id and proto is not self: try: proto._send(0, broadcast) @@ -463,9 +508,10 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, {"type": "ack", "v": MNP_VERSION}) def connection_lost(self, exc) -> None: - peers = self._ctx.get("_peers") - if peers and self._user_id: - peers.pop(self._user_id, None) + if self._user_id: + self._peer_registry().pop(self._user_id, None) + for task in list(self._tasks): + task.cancel() super().connection_lost(exc) def _send(self, stream_id: int, obj: dict) -> None: @@ -558,7 +604,7 @@ class QuicChunkServer: self._ctx["groups"] = groups self._denylist = denylist or Denylist() self._ctx["denylist"] = self._denylist - self._ctx["_peers"] = {} + # Peer sets are per group now — see _MNPServerProtocol._peer_registry(). self._host = host self._port = port self._cert_path = cert_path or Path.home() / ".config/meshbay/node_tls.crt" diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 003cd23..64ba75c 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -143,6 +143,16 @@ def _link_preview_cache_put(url: str, value: dict) -> None: _link_preview_cache.pop(oldest, None) _link_preview_cache[url] = (time.time(), value) + +# A member pasting a link is normal; a member — or a hub minting tokens for many +# accounts — firing hundreds is amplification/DoS and a way to make the node +# reach arbitrary hosts on demand (finding M3). Only a real outbound fetch is +# counted (a cache hit costs nothing), and the ceilings are generous enough that +# ordinary chat never meets them. +_LINK_PREVIEW_RATE_WINDOW = 60.0 +_LINK_PREVIEW_RATE_PER_CONN = 15 +_LINK_PREVIEW_RATE_NODE = 60 + # Upload limits (finding C5a). Uploads used to land directly in the shared root under # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its @@ -3588,6 +3598,27 @@ class WebRTCPeerSession: ], }) + def _link_preview_rate_ok(self) -> bool: + """ + True when this preview fetch is within both the per-connection and the + node-wide window; records it when so, and both counts are trimmed to the + window on every call so the lists cannot grow without bound. + """ + now = time.monotonic() + w = _LINK_PREVIEW_RATE_WINDOW + mine = [t for t in getattr(self, "_link_preview_hits", []) if now - t < w] + node = [t for t in self._ctx.get("link_preview_hits", []) if now - t < w] + if (len(mine) >= _LINK_PREVIEW_RATE_PER_CONN + or len(node) >= _LINK_PREVIEW_RATE_NODE): + self._link_preview_hits = mine + self._ctx["link_preview_hits"] = node + return False + mine.append(now) + node.append(now) + self._link_preview_hits = mine + self._ctx["link_preview_hits"] = node + return True + async def _do_link_preview_request(self, msg: dict) -> None: """ Unfurl a URL a member pasted into chat (draft-v6 §2.7 enrichment rule: @@ -3608,6 +3639,15 @@ class WebRTCPeerSession: self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION}) return + if not self._link_preview_rate_ok(): + # Same shape as any other miss — the client shows the bare link. A + # rate-limited result is not cached, so it is retried once the + # window clears rather than pinned as "no preview". + log.debug("link_preview_req: rate-limited (peer=%s)", self._peer_id) + self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, + "url": key, "ok": False}) + return + resp: dict = {"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, "url": key, "ok": False} try: diff --git a/packages/meshbay-node/tests/test_link_preview_request.py b/packages/meshbay-node/tests/test_link_preview_request.py index d975f91..fe7dec6 100644 --- a/packages/meshbay-node/tests/test_link_preview_request.py +++ b/packages/meshbay-node/tests/test_link_preview_request.py @@ -34,9 +34,10 @@ def _clear_cache(): webrtc_server._link_preview_cache.clear() -def _session(media_cache): +def _session(media_cache, ctx=None): s = WebRTCPeerSession.__new__(WebRTCPeerSession) - s._ctx = {"media_cache": media_cache} + s._ctx = ctx if ctx is not None else {"media_cache": media_cache} + s._peer_id = "t" s.sent = [] s._send = s.sent.append return s @@ -79,6 +80,51 @@ async def test_unfurlable_failure_is_ok_false(media_cache, monkeypatch): assert "image_thumb_hash" not in resp +async def test_rate_limit_per_connection(media_cache, monkeypatch): + """A member firing many previews is bounded; over the ceiling the reply is + a plain `ok: false` (bare link) and no outbound fetch is made.""" + monkeypatch.setattr(webrtc_server, "_LINK_PREVIEW_RATE_PER_CONN", 3) + calls = {"n": 0} + + async def counting_preview(url, **k): + calls["n"] += 1 + return {"url": url, "title": "x", "description": None, + "site_name": None, "image_url": None} + monkeypatch.setattr(linkpreview, "fetch_preview", counting_preview) + + s = _session(media_cache) + for i in range(3): + await s._do_link_preview_request({"url": f"https://example.com/{i}"}) + assert calls["n"] == 3 + assert all(r["ok"] for r in s.sent) + + await s._do_link_preview_request({"url": "https://example.com/over"}) + assert calls["n"] == 3 # not fetched + assert s.sent[-1]["ok"] is False + + +async def test_rate_limit_is_node_wide(media_cache, monkeypatch): + """Two connections share the node-wide ceiling.""" + monkeypatch.setattr(webrtc_server, "_LINK_PREVIEW_RATE_PER_CONN", 100) + monkeypatch.setattr(webrtc_server, "_LINK_PREVIEW_RATE_NODE", 2) + calls = {"n": 0} + + async def counting_preview(url, **k): + calls["n"] += 1 + return {"url": url, "title": "x", "description": None, + "site_name": None, "image_url": None} + monkeypatch.setattr(linkpreview, "fetch_preview", counting_preview) + + ctx = {"media_cache": media_cache} + a, b = _session(media_cache, ctx), _session(media_cache, ctx) + await a._do_link_preview_request({"url": "https://example.com/a"}) + await b._do_link_preview_request({"url": "https://example.com/b"}) + await b._do_link_preview_request({"url": "https://example.com/c"}) + + assert calls["n"] == 2 + assert b.sent[-1]["ok"] is False + + async def test_second_request_for_the_same_url_is_served_from_cache(media_cache, monkeypatch): calls = {"n": 0} diff --git a/packages/meshbay-node/tests/test_linkpreview.py b/packages/meshbay-node/tests/test_linkpreview.py index 0dd951a..a14b173 100644 --- a/packages/meshbay-node/tests/test_linkpreview.py +++ b/packages/meshbay-node/tests/test_linkpreview.py @@ -48,6 +48,30 @@ def test_safe_url_refuses(url): safe_url(url) +@pytest.mark.parametrize("url", [ + "http://example.com:22/x", # SSH + "http://example.com:3306/x", # MySQL + "http://example.com:6379/x", # Redis + "http://example.com:9200/x", # Elasticsearch + "http://example.com:5000/x", # a common internal admin port +]) +def test_safe_url_refuses_non_web_ports(url, resolves_public): + with pytest.raises(UnsafeURL): + safe_url(url) + + +@pytest.mark.parametrize("url", [ + "http://example.com/x", # implicit 80 + "https://example.com/x", # implicit 443 + "http://example.com:80/x", + "https://example.com:443/x", + "http://example.com:8080/x", + "https://example.com:8443/x", +]) +def test_safe_url_allows_the_web_ports(url, resolves_public): + assert safe_url(url) == url + + def test_safe_url_accepts_a_public_host(resolves_public): assert safe_url("https://example.com/some/page") == "https://example.com/some/page" @@ -153,3 +177,19 @@ async def test_fetch_image_downscales(resolves_public): assert jpeg and jpeg[:2] == b"\xff\xd8" # JPEG SOI with Image.open(BytesIO(jpeg)) as im: assert max(im.size) <= linkpreview._IMAGE_MAX_DIM + + +async def test_fetch_image_refuses_a_decompression_bomb(resolves_public, monkeypatch): + from io import BytesIO + + from PIL import Image + # A tiny file that reports enormous dimensions from its header alone. + monkeypatch.setattr(linkpreview, "_MAX_IMAGE_PIXELS", 1_000_000) + buf = BytesIO() + Image.new("RGB", (2000, 2000), (0, 0, 0)).save(buf, format="PNG") # 4 MP > cap + bomb = buf.getvalue() + + def handler(request): + return httpx.Response(200, headers={"content-type": "image/png"}, content=bomb) + async with _client(handler) as c: + assert await linkpreview.fetch_image("https://example.com/x.png", client=c) is None diff --git a/packages/meshbay-node/tests/test_quic_enabled.py b/packages/meshbay-node/tests/test_quic_enabled.py new file mode 100644 index 0000000..c0c1568 --- /dev/null +++ b/packages/meshbay-node/tests/test_quic_enabled.py @@ -0,0 +1,53 @@ +""" +The QUIC MNP listener is off unless the operator turns it on. + +No shipping client speaks QUIC (browser and desktop use WebRTC; the hub-less +`group://` sidecar is unbuilt), so a node that started it by default would only +be exposing a UDP port. `daemon.py` gates `QuicChunkServer` on +`self._config.node.quic_enabled`; these follow the value down the config path. +""" + +import textwrap +from pathlib import Path + +from meshbay_node.config import load_config + + +def _cfg(tmp_path: Path, body: str): + p = tmp_path / "node.toml" + p.write_text(textwrap.dedent(body)) + return load_config(p) + + +def test_off_by_default(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + quic_port = 19010 + """) + assert cfg.node.quic_enabled is False + + +def test_the_operator_turns_it_on(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + quic_enabled = true + """) + assert cfg.node.quic_enabled is True + + +def test_the_environment_can_force_it_on(tmp_path, monkeypatch): + monkeypatch.setenv("MESHBAY_QUIC_ENABLED", "1") + cfg = _cfg(tmp_path, """ + [node] + quic_enabled = false + """) + assert cfg.node.quic_enabled is True + + +def test_the_environment_can_force_it_off(tmp_path, monkeypatch): + monkeypatch.setenv("MESHBAY_QUIC_ENABLED", "false") + cfg = _cfg(tmp_path, """ + [node] + quic_enabled = true + """) + assert cfg.node.quic_enabled is False |