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 <bot@rp1.run>
Signed-off-by: Prem Pillai <prem@squareup.com>
This commit is contained in:
Prem Pillai
2026-09-28 15:22:34 +10:00
parent 6ed0866113
commit 4e32f0aa70

View File

@@ -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() {