From 4e32f0aa707d7bf4aeb8a383f3e7eaef88204df1 Mon Sep 17 00:00:00 2001 From: Prem Pillai Date: Mon, 28 Sep 2026 15:22:34 +1000 Subject: [PATCH] fix(relay): reject observer frames from banned signers over HTTP Observer frames return before the ingest write-path ban gate, and NIP-98 auth does not consult bans, so a banned owner could keep sending control frames through POST /events. Check the authoring pubkey's ban state before accepting the frame, failing closed on lookup errors. Generated with AI Co-Authored-By: rp1 Signed-off-by: Prem Pillai --- crates/buzz-relay/src/handlers/ingest.rs | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 485eeb0ffc..6b2e3b6b6c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2439,6 +2439,27 @@ async fn ingest_event_inner( } if kind_u32 == KIND_AGENT_OBSERVER_FRAME { + // Observer frames return before the write-path ban gate below, and + // NIP-98 auth never consults bans. Re-check the authoring pubkey here so + // a banned owner cannot keep sending control frames over HTTP. + match state + .db + .moderation_restriction_state(tenant.community(), auth.pubkey().as_bytes()) + .await + { + Ok(r) if r.banned => { + return Err(IngestError::AuthFailed( + "blocked: you are banned from this community".to_string(), + )); + } + Ok(_) => {} + Err(e) => { + // Fail closed: a DB error must not let a banned actor through. + return Err(IngestError::Internal(format!( + "error: internal error checking restriction state: {e}" + ))); + } + } super::event::ingest_agent_observer_event(state, tenant, &event, None).await?; emit( tracer, @@ -3736,6 +3757,52 @@ mod postgres_tests { )); } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn http_observer_frame_rejects_banned_owner_control_frame() { + let (state, tenant, agent, owner) = observer_ingest_fixture().await; + let encrypted = buzz_core::observer::encrypt_observer_payload( + &owner, + &agent.public_key(), + &serde_json::json!({"kind": "cancel"}), + ) + .expect("encrypt control payload"); + let control = || { + buzz_sdk::build_agent_observer_frame( + &agent.public_key().to_hex(), + &agent.public_key().to_hex(), + buzz_core::observer::OBSERVER_FRAME_CONTROL, + &encrypted, + ) + .expect("build control frame") + .sign_with_keys(&owner) + .expect("sign control frame") + }; + + assert!( + ingest_observer_test_event(&state, &tenant, &owner, control()) + .await + .is_ok(), + "unbanned owner control frame should be accepted" + ); + + state + .db + .ban_community_member( + tenant.community(), + &owner.public_key().to_bytes(), + &Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("ban owner"); + assert!(matches!( + ingest_observer_test_event(&state, &tenant, &owner, control()).await, + Err(IngestError::AuthFailed(message)) if message.contains("banned") + )); + } + #[tokio::test] #[ignore = "requires Postgres and Redis"] async fn http_unknown_ephemeral_kind_remains_restricted() {