Banned relay admins can still run NIP-43 admin commands (kinds 9030–9033) over HTTP
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
`ingest_event` exempts the NIP-43 relay-admin kinds (9030–9033) from the durable community-ban gate, and `handle_relay_admin_event` doesn't check ban state itself. So a banned admin or owner can still add and remove relay members and rewrite the workspace icon by posting a signed NIP-98 request to `POST /events`. No open WebSocket needed, the HTTP bridge is enough.
This is the same class of bug PR #1915 fixed for the moderation commands 9040–9044. That fix wasn't extended to the 9030 range.
## Why it happens
`crates/buzz-relay/src/handlers/ingest.rs:1639` gates the ban/timeout write-block on moderation-command kinds *and* relay-admin kinds:
```rust
if !buzz_core::kind::is_moderation_command_kind(kind_u32) && !is_relay_admin_kind(kind_u32) {
// read moderation_restriction_state, reject if banned / timed out
}
```
The comment just above (`ingest.rs:1626-1627`) explains the moderation-command exemption and says relay-admin commands "retain their separate authorization policy." They don't have one that covers bans. `handle_relay_admin_event` (`crates/buzz-relay/src/handlers/relay_admin.rs:108`) does two things before dispatching: a ±120s freshness check (`relay_admin.rs:115-130`) and a `get_relay_member` role lookup (`relay_admin.rs:133-140`). Grepping that file for `banned`, `restriction` or `community_ban` returns nothing.
The moderation-command handler does check, and its comment names this exact hole:
```rust
// A ban is an admission boundary, not only a WebSocket-auth check. HTTP
// NIP-98 requests and already-authenticated sockets can reach this handler
// without passing through a fresh NIP-42 challenge, so enforce the durable
// tenant-scoped restriction before any command can lift or mutate it.
```
`crates/buzz-relay/src/handlers/moderation_commands.rs:99-108`
Three things make the bypass reachable:
1. A ban doesn't remove the role. `ban_member` (`crates/buzz-db/src/moderation.rs:314-344`) only upserts into `community_bans`. `relay_members` is untouched, so `sender_role` still reads `admin` or `owner`.
2. The HTTP path never consults ban state. `submit_event` (`crates/buzz-relay/src/api/bridge.rs:613`) verifies NIP-98, then `submit_event_authed` runs `enforce_relay_membership` (`bridge.rs:800`), which is a `relay_members` check and not a ban check, and calls `ingest_event` at `bridge.rs:833`.
3. The ban is only enforced at the NIP-42 auth seam (`crates/buzz-relay/src/handlers/auth.rs:121`), which an HTTP request never crosses.
## Reproduction, from source
1. Owner bans an admin: kind:9041 with `["p", ]`.
2. `community_bans` now has `banned = true` for that admin. `relay_members` still has their `admin` row.
3. The admin signs a kind:9031 (remove member) targeting any `member`-role pubkey and posts it to `POST /events` with a NIP-98 `Authorization` header.
4. `ingest.rs:1639` skips the ban gate because `is_relay_admin_kind(9031)` is true. `relay_admin.rs:133` reads the still-present `admin` role. The member is removed and a NIP-43 kind:8000 announcement goes out.
Same for 9030 (add member) and 9033 (set workspace icon). 9032 needs `owner`, so that one applies to a banned owner rather than a banned admin.
## Impact
A community that bans an abusive admin hasn't actually stopped them. They can keep churning the member roster and rewriting the workspace icon until someone notices and deletes the `relay_members` row by hand, which isn't something the ban flow does or tells anyone to do.
It also cuts against `VISION_MODERATION.md:45`: "Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped." And it partly defeats the guard rail at `VISION_MODERATION.md:43` ("an admin cannot ban or time out an owner or another admin"), because moderation capability is itself derived from `relay_members` (`crates/buzz-relay/src/handlers/moderation_authz.rs:93-100`), the same table 9031 and 9032 mutate.
## Suggested fix
Mirror `moderation_commands.rs:99-108` in `handle_relay_admin_event`: read `moderation_restriction_state` for the sender and reject a banned actor before the role lookup.
A timed-out actor should probably still get through, for the same reason moderation commands allow it, since a timeout is a content write-block rather than an admin-capability block. That's your call though, and I'd rather ask than guess.
The smaller alternative is dropping `&& !is_relay_admin_kind(kind_u32)` from `ingest.rs:1639` and letting the shared gate cover it. Fewer lines, but it also applies the timeout block, which changes behaviour for timed-out admins.
`moderation_commands.rs:619` already has `ensure_actor_not_banned` coverage at `:631` and `:641` plus a `make_event` helper at `:645`, so the test shape ports over directly.
Happy to send a PR either way, just say which shape you'd prefer.
Contributor guide
Assessment
This issue has not been assessed yet.