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 into community_bans. relay_members is untouched, so sender_role still reads admin or owner.
- 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.
- 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_bans now has banned = true for that admin. relay_members still has their admin row.
- 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.
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.
Summary
ingest_eventexempts the NIP-43 relay-admin kinds (9030–9033) from the durable community-ban gate, andhandle_relay_admin_eventdoesn'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 toPOST /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:1639gates the ban/timeout write-block on moderation-command kinds and relay-admin kinds: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 aget_relay_memberrole lookup (relay_admin.rs:133-140). Grepping that file forbanned,restrictionorcommunity_banreturns nothing.The moderation-command handler does check, and its comment names this exact hole:
crates/buzz-relay/src/handlers/moderation_commands.rs:99-108Three things make the bypass reachable:
ban_member(crates/buzz-db/src/moderation.rs:314-344) only upserts intocommunity_bans.relay_membersis untouched, sosender_rolestill readsadminorowner.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.crates/buzz-relay/src/handlers/auth.rs:121), which an HTTP request never crosses.Reproduction, from source
["p", <admin>].community_bansnow hasbanned = truefor that admin.relay_membersstill has theiradminrow.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_membersrow 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 atVISION_MODERATION.md:43("an admin cannot ban or time out an owner or another admin"), because moderation capability is itself derived fromrelay_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-108inhandle_relay_admin_event: readmoderation_restriction_statefor 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)fromingest.rs:1639and 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:619already hasensure_actor_not_bannedcoverage at:631and:641plus amake_eventhelper at:645, so the test shape ports over directly.Happy to send a PR either way, just say which shape you'd prefer.