diff --git a/crates/buzz-relay/src/api/typing_postgres_tests.rs b/crates/buzz-relay/src/api/typing_postgres_tests.rs index 3883a92e19..9e9c700957 100644 --- a/crates/buzz-relay/src/api/typing_postgres_tests.rs +++ b/crates/buzz-relay/src/api/typing_postgres_tests.rs @@ -202,3 +202,22 @@ async fn typing_without_channel_is_rejected() { "{body}" ); } + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn oversized_typing_is_rejected() { + let f = Fixture::new().await; + let event = EventBuilder::new(Kind::Custom(20002), "x".repeat(2048)) + .tags([Tag::parse(["h".to_string(), f.channel.to_string()]).unwrap()]) + .sign_with_keys(&f.member) + .unwrap(); + let (status, body) = f.post(&f.member, &event).await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("too large"), + "{body}" + ); +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 8dd05e5245..f6cbe7af7a 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1056,6 +1056,12 @@ async fn publish_channel_ephemeral( Ok(()) } +/// Upper bound on content plus tag values for an HTTP typing indicator. +/// Real indicators carry empty content and at most three short tags (`h`, +/// root `e`, reply `e`, roughly 250 bytes). Without a cap a member could post +/// up to the HTTP body limit and have it fanned out to every subscriber. +const MAX_HTTP_TYPING_BYTES: usize = 1024; + /// Publishes a typing indicator (kind:20002) submitted through HTTP /// `POST /events`, for clients that sign but hold no WebSocket, such as /// app-hosted agents. Applies the gates the WebSocket path applies to @@ -1075,6 +1081,18 @@ pub(crate) async fn publish_http_typing( let ch_id = super::ingest::extract_channel_id(&event).ok_or_else(|| { IngestError::Rejected("invalid: typing indicator needs a channel UUID h tag".into()) })?; + let size = event.content.len() + + event + .tags + .iter() + .flat_map(|tag| tag.as_slice()) + .map(String::len) + .sum::(); + if size > MAX_HTTP_TYPING_BYTES { + return Err(IngestError::Rejected(format!( + "invalid: typing indicator too large (max {MAX_HTTP_TYPING_BYTES} bytes of content and tags)" + ))); + } let event_clone = event.clone(); match tokio::task::spawn_blocking(move || verify_event(&event_clone)).await { Ok(Ok(())) => {}