block / block/buzz

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

Open
#3,020 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.