mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Block banned actors from moderation commands (BUZZ-SEC-007) (#1915)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user