[codex] Block banned actors from moderation commands (BUZZ-SEC-007) (#1915)

This commit is contained in:
Jordan Mecom
2026-07-17 16:24:43 +00:00
committed by GitHub
parent d3ce971fc7
commit caa195ca58
3 changed files with 51 additions and 10 deletions
+6 -5
View File
@@ -1498,9 +1498,9 @@ async fn ingest_event_inner(
// mutations. They are never stored or fanned out as ordinary events; the
// handler writes the durable audit/enforcement rows after its own capability
// authorization. These commands are intentionally routed before the
// timeout/write-block gate below: restriction-lifting commands must remain
// available so a wrongly restricted admin is not stranded, while banned
// actors are handled by the auth seam and live-disconnect enforcement.
// timeout/write-block gate below so a timed-out admin can lift a timeout.
// The handler independently checks the durable ban state before executing
// any command, which also covers NIP-98 and missed live disconnects.
if buzz_core::kind::is_moderation_command_kind(kind_u32) {
super::moderation_commands::handle_moderation_command(tenant, state, &event)
.await
@@ -1521,8 +1521,9 @@ async fn ingest_event_inner(
// subscriber reconnect window), a banned member's open socket would keep
// writing indefinitely. So the ban is re-checked here — this write-path gate
// is the durable backstop the fan-out's best-effort delivery relies on.
// Moderation/relay-admin commands are exempt: a restriction must never
// disarm the tools used to lift or manage it.
// Moderation commands enforce bans inside their handler and remain exempt
// here only so timeouts do not disarm the tool used to lift them. Relay-admin
// commands retain their separate authorization policy.
//
// Scope: this gate checks the *authoring* pubkey only, with no NIP-OA
// owner→agent cascade. That cascade lives at the auth seam for bans, where
@@ -156,11 +156,10 @@ fn decide_authority(
// fellow admin — only the owner may action an admin. The guard trips only
// on a target *role* of owner/admin: a target with no `relay_members` row
// (a drive-by spammer who already left) is bannable. Unban/Untimeout lift
// a restriction and are intentionally unguarded — a banned admin can't
// self-unban (banned means blocked at the auth seam before any command
// runs), so the only reachable case is an admin lifting a fellow admin's
// restriction, which is benign, audited, and owner-reversible; guarding it
// would instead strand a wrongly-banned admin behind an owner-only unlock.
// a restriction and are intentionally unguarded at this role seam. The
// command handler separately rejects a banned actor on every transport,
// so the reachable case is an unrestricted admin lifting another admin's
// restriction; that remains benign, audited, and owner-reversible.
Some("admin") => {
if matches!(action, ModerationAction::Ban | ModerationAction::Timeout)
&& matches!(target_role, Some("owner") | Some("admin"))
@@ -96,6 +96,17 @@ pub async fn handle_moderation_command(
let kind = event.kind.as_u16() as u32;
let actor = event.pubkey.to_bytes().to_vec();
// 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.
let restriction = state
.db
.moderation_restriction_state(tenant.community(), &actor)
.await
.map_err(|e| error(format!("database error checking restriction state: {e}")))?;
ensure_actor_not_banned(&restriction)?;
// Freshness: reject stale/replayed commands (they are never stored).
let event_ts = event.created_at.as_secs() as i64;
let now = std::time::SystemTime::now()
@@ -121,6 +132,15 @@ pub async fn handle_moderation_command(
}
}
fn ensure_actor_not_banned(
restriction: &buzz_db::moderation::RestrictionState,
) -> Result<(), String> {
if restriction.banned {
return Err("blocked: you are banned from this community".to_string());
}
Ok(())
}
// ── 9040: ban ───────────────────────────────────────────────────────────────
async fn handle_ban(
@@ -598,8 +618,29 @@ fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use nostr::{EventBuilder, Keys, Kind, Tag};
#[test]
fn banned_admin_cannot_reach_an_unban_command() {
let banned = buzz_db::moderation::RestrictionState {
banned: true,
muted_until: None,
};
assert_eq!(
ensure_actor_not_banned(&banned),
Err("blocked: you are banned from this community".to_string())
);
// A timeout remains a write restriction rather than an authentication
// ban, so an authorized moderator can still lift it.
let timed_out = buzz_db::moderation::RestrictionState {
banned: false,
muted_until: Some(Utc::now() + Duration::minutes(5)),
};
assert!(ensure_actor_not_banned(&timed_out).is_ok());
}
/// Build a signed event with the given kind, timestamp, and tags.
fn make_event(kind: u16, created_at_secs: u64, tags: Vec<Vec<String>>) -> Event {
let keys = Keys::generate();