From 2603c7b89e0d98800ec0fd460dac04a1815ddbcf Mon Sep 17 00:00:00 2001 From: ART Date: Fri, 9 Oct 2026 10:42:21 -0700 Subject: [PATCH] fix(relay): cap HTTP typing indicator size at 1 KiB POST /events skips the 256 KB ingest content cap for typing indicators, so a member could post up to the HTTP body limit and have it fanned out to every subscriber. Real indicators carry empty content and at most three short tags, so reject content plus tag values above 1 KiB. Co-Authored-By: Claude Opus 5.5 Signed-off-by: ART --- .../src/api/typing_postgres_tests.rs | 19 +++++++++++++++++++ crates/buzz-relay/src/handlers/event.rs | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) 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(())) => {}