Skip to content

Banned relay admins can still run NIP-43 admin commands (kinds 9030–9033) over HTTP #3020

Description

@aeonbilal

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:

  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", <admin>].
  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions