mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary `ingest_event`'s durable write-path restriction gate exempts NIP-43 relay-admin kinds **9030–9033**, so that a *timed-out* admin keeps administrative capability. That exemption was ban-blind, and `handle_relay_admin_event` performed no restriction check of its own. A **banned** admin or owner could still add members, remove members, change member roles, and set the workspace icon by posting a signed NIP-98 request to `POST /events`. No open WebSocket required. Reported externally by **Bilal Syed** (also filed publicly as #3020 before he read `SECURITY.md`). Verified true, reproduced live, and found slightly worse than reported. Same class as BUZZ-SEC-007, which PR #1915 closed for moderation command kinds 9040–9044. That fix was never extended to the 9030 range. ## Why it worked - `handlers/ingest.rs:1639` skipped the restriction check when `is_relay_admin_kind(kind)` was true. - `handlers/relay_admin.rs` did a freshness check and a role lookup only — zero restriction reads in the file. - A ban does not remove the role: `ban_member` (`buzz-db/src/moderation.rs:314`) writes only `community_bans`, so the `relay_members` admin row survives. - The HTTP path never consulted ban state — `enforce_relay_membership` is a bare `SELECT 1 FROM relay_members`. - The ban was enforced only at the NIP-42 auth seam, which an HTTP request never crosses. **Worse than reported:** the report covered remove (9031) and icon (9033). Add (**9030**) works too, so a banned admin can *plant* new members. That matters because `moderation_authz.rs:163-170` derives "an admin cannot ban an owner or fellow admin" from `relay_members` — the very table 9030/9031 mutate. A banned admin could seed accomplices into the roster the ban was meant to stop them touching. Also of note: `moderation_authz.rs:158-165` already asserts in a comment that *"The command handler separately rejects a banned actor on every transport."* `relay_admin.rs` was the one command handler not holding that invariant. ## The fix Enforce the durable ban **inside `handle_relay_admin_event`** — the reporter's own suggested shape, and the `moderation_commands.rs:99-108` precedent. Deliberately **not** the one-token alternative of dropping `&& !is_relay_admin_kind(kind_u32)` at `ingest.rs:1639`: that would also start blocking *timed-out* admins, silently changing policy. Bans are refused; timeouts still administer, which is the entire reason the exemption exists. `handle_relay_admin_event` becomes a thin admission wrapper around an unchanged `execute_relay_admin_command` body, so no future early return inside that body can precede the check. The check therefore also necessarily precedes the freshness check. **The refusal category is part of the security contract**, so this returns a typed `RelayAdminError` rather than a string. A `blocked:` string would have kept the right wire text but returned **400** instead of **403** (`api/bridge.rs:845` vs `:858`), and would have reported a restriction-DB outage as a client error: | Variant | Ingest | Wire | HTTP | |---|---|---|---| | `Banned` | `AuthFailed` | `blocked: you are banned from this community` | **403** | | `Rejected(..)` | `Rejected` | `invalid: …` | 400 (unchanged) | | `Internal(..)` | `Internal` | `error: …` (sanitized) | **500** | ## Verification Live over real HTTP against an isolated relay, all four exempt kinds refused, DB checked after each for non-mutation: ``` [banned] 9031 remove -> 403 blocked: you are banned from this community [banned] 9030 add -> 403 blocked: you are banned from this community [banned] 9032 change role -> 403 blocked: you are banned from this community [banned] 9033 set icon -> 403 blocked: you are banned from this community ``` Victim still `member`, planted key absent, role target unchanged, icon still NULL. 9032 required a banned **owner** to be a real test, since it is owner-only. - **Mutation-tested.** The admission decision is the pure `admits_relay_admin_command(&RestrictionState)`, covered by the *default* suite. Neutering it fails `banned_actor_is_not_admitted_to_a_relay_admin_command`. The first version of this patch would have stayed green if someone deleted the check — that gap is closed. The unit test does not prove handler *wiring*; the `#[ignore]`d live E2E is what checks linkage. - **Fail-closed proven empirically**, by manual fault injection rather than assertion: renaming `community_bans.banned` out from under the running relay yields 500, no mutation, and no schema detail leaked to the client. - Negative/positive controls: timed-out admin still administers *and* is still content-write-blocked; clean admin unaffected with mutation confirmed; non-admin still gets `invalid:`/400. - Reviewed iteratively by **@Mari** over three rounds; final approval at 9/10+ on minimalness, elegance, and correctness. She also ran an independent deep regression pass on an isolated stack (odd port 44391) covering channel lifecycle, membership, messages/replies/search/edit/delete, reactions, canvas, DMs, and moderation transitions — no regressions. - `cargo fmt --all --check`, `cargo clippy -p buzz-relay --all-targets -D warnings`, `buzz-core` 229/229, `buzz-cli` 250/250, `run-tests.sh unit` all five packages green. - `buzz-relay --lib`: **756 passed / 1 failed**. The sole failure `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200) is **pre-existing** — reproduced identically in a detached worktree at merge base `00ecf2c`. ## Notes for the reviewer - Merged `origin/main` in as a merge commit rather than rebasing, per instruction. No conflicts; the eight incoming commits touch none of the three files here. Closest neighbour is `00ecf2c` (kind:9000 NIP-29 *channel* role authz) — disjoint from this NIP-43 *relay-admin* fix. - **This does not close the class.** Two separate items remain open, deliberately excluded to keep an externally-known security fix reviewable: 1. **Command kinds dispatch before the gate.** `is_command_kind` fires at `ingest.rs:1561`, ~80 lines *before* the restriction gate, and `command_executor.rs` has no restriction read. Measured live: a banned member can still open a DM (41010 → 200). 41011/41012/30620/46030/46031 unprobed. Needs per-kind semantics enumerated first (reports allowed while banned; moderation commands allow timeouts but reject bans; ordinary writes reject both). 2. **`moderation_commands.rs` maps its own restriction-DB failure to 400, not 500**, and leaks the raw Postgres message to the client. - One correction for the public issue: its repro step 1 says `kind:9041`, which is **unban**. The ban is **9040** (`KIND_MODERATION_BAN`, `buzz-core/src/kind.rs:298`). Following the steps verbatim yields a false negative. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> --------- Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>