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 <noreply@anthropic.com>
Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
This commit is contained in:
ART
2026-10-09 10:42:21 -07:00
parent 7495aadf8f
commit 2603c7b89e
2 changed files with 37 additions and 0 deletions

View File

@@ -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}"
);
}

View File

@@ -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::<usize>();
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(())) => {}