Repository navigation
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:
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:
// 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:
- A ban doesn't remove the role.
ban_member(crates/buzz-db/src/moderation.rs:314-344) only upserts intocommunity_bans.relay_membersis untouched, sosender_rolestill readsadminorowner. - The HTTP path never consults ban state.
submit_event(crates/buzz-relay/src/api/bridge.rs:613) verifies NIP-98, thensubmit_event_authedrunsenforce_relay_membership(bridge.rs:800), which is arelay_memberscheck and not a ban check, and callsingest_eventatbridge.rs:833. - 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
- Owner bans an admin: kind:9041 with
["p", <admin>]. community_bansnow hasbanned = truefor that admin.relay_membersstill has theiradminrow.- The admin signs a kind:9031 (remove member) targeting any
member-role pubkey and posts it toPOST /eventswith a NIP-98Authorizationheader. ingest.rs:1639skips the ban gate becauseis_relay_admin_kind(9031)is true.relay_admin.rs:133reads the still-presentadminrole. 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.
Activity
fix(security): enforce durable community ban on NIP-43 relay-admin ki…
fix(security): enforce durable community ban on NIP-43 relay-admin ki…
fix(security): enforce durable community ban on NIP-43 relay-admin ki…