fix(relay): bound HTTP typing by serialized size; test the write fence

The 1 KiB cap summed content and tag values only, so an event with an h
tag plus many empty tags counted as a few bytes while its JSON could
reach the HTTP body limit. Measure the serialized event instead (2 KiB,
which leaves room for id, pubkey and sig on a ~600-byte real indicator).
The oversize test now covers content, one large tag value, and 10k empty
tags.

Add a test for the community write fence on the HTTP typing path. Over
HTTP a quiescing community's host no longer binds, so the test binds
the tenant first and then quiesces, which is the window the fence
guards.

Update comments and docs that said HTTP rejects every ephemeral kind;
only typing (kind:20002) is accepted.

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 12:15:27 -07:00
parent 2603c7b89e
commit 27b5a29405
7 changed files with 109 additions and 29 deletions

View File

@@ -96,8 +96,8 @@ fn current_working_directory() -> Result<String> {
/// Publish a kind:20001 presence update event via the WebSocket connection.
///
/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence
/// updates must be routed through the WS path.
/// The HTTP bridge rejects presence (kind:20001); typing (kind:20002) is the
/// only ephemeral kind it accepts. Presence updates must use the WS path.
///
/// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching
/// the desktop client's format. The relay stores this in Redis and synthesizes

View File

@@ -333,7 +333,7 @@ buzz users presence --pubkeys "$MY_PUBKEY" | jq .
buzz users set-presence --status online | jq .
buzz users set-presence --status away | jq .
buzz users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
# Note: set-presence may fail — the HTTP bridge accepts kind:20001 (presence) only via WebSocket
# users set-status — NIP-38 kind:30315 on the d:general coordinate
buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq .

View File

@@ -1174,7 +1174,8 @@ impl BuzzClient {
/// Publish an ephemeral event via WebSocket with NIP-42 authentication.
///
/// The relay rejects ephemeral kinds (20000–29999) over HTTP. Delegates to
/// The relay accepts only typing (kind:20002) of the ephemeral kinds
/// (20000–29999) over HTTP. Delegates to
/// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth,
/// EVENT send, OK wait, and graceful close.
pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result<String, CliError> {

View File

@@ -207,17 +207,99 @@ async fn typing_without_channel_is_rejected() {
#[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()])
let h = || Tag::parse(["h".to_string(), f.channel.to_string()]).unwrap();
let empty = || Tag::parse([String::new()]).unwrap();
let cases = [
("content", "x".repeat(2048), vec![h()]),
(
"tag value",
String::new(),
vec![
h(),
Tag::parse(["e".to_string(), "x".repeat(2048)]).unwrap(),
],
),
(
"empty tags",
String::new(),
std::iter::once(h())
.chain(std::iter::repeat_with(empty).take(10_000))
.collect(),
),
];
for (case, content, tags) in cases {
let event = EventBuilder::new(Kind::Custom(20002), content)
.tags(tags)
.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_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"{case}: {body}"
);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("too large"),
"{body}"
"{case}: {body}"
);
}
}
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn fenced_community_typing_is_rejected() {
use futures::StreamExt;
let f = Fixture::new().await;
// Over HTTP a quiescing community's host no longer binds (404), so the
// fence only matters when quiescing starts after the request has bound its
// tenant. Reproduce that order: bind first, then quiesce, then publish.
let tenant = crate::tenant::bind_community(&f.state.db, &f.host)
.await
.unwrap();
// Enter the deletion executor's transaction scope in this disposable
// fixture; the DB rejects unfenced ad-hoc state changes.
let mut tx = f.pool.begin().await.unwrap();
sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)")
.bind(f.community.to_string())
.execute(&mut *tx)
.await
.unwrap();
sqlx::query("UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1")
.bind(f.community.as_uuid())
.execute(&mut *tx)
.await
.unwrap();
tx.commit().await.unwrap();
let topic = buzz_pubsub::EventTopicKey {
community_id: f.community,
topic: buzz_pubsub::EventTopic::Channel(f.channel),
}
.redis_channel();
let client = redis::Client::open(f.state.config.redis_url.as_str()).unwrap();
let mut pubsub = client.get_async_pubsub().await.unwrap();
pubsub.subscribe(&topic).await.unwrap();
let err = crate::handlers::event::publish_http_typing(
&f.state,
&tenant,
f.typing(&f.member, Some(f.channel)),
f.member.public_key(),
)
.await
.unwrap_err();
assert!(
matches!(&err, IngestError::Rejected(msg) if msg == "restricted: community writes are fenced"),
"{err:?}"
);
let mut messages = pubsub.into_on_message();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(500), messages.next())
.await
.is_err(),
"fenced typing indicator must not be published"
);
}

View File

@@ -1056,11 +1056,13 @@ 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;
/// Upper bound on the serialized JSON of an HTTP typing indicator. Real
/// indicators carry empty content and at most three short tags (`h`, root
/// `e`, reply `e`), about 600 bytes with id, pubkey and sig. Measuring the
/// serialized event, not just content and tag values, also counts tag
/// structure, so many empty tags cannot slip a near-body-limit event past the
/// cap and into fan-out to every subscriber.
const MAX_HTTP_TYPING_BYTES: usize = 2048;
/// Publishes a typing indicator (kind:20002) submitted through HTTP
/// `POST /events`, for clients that sign but hold no WebSocket, such as
@@ -1081,16 +1083,9 @@ 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 {
if nostr::JsonUtil::as_json(&event).len() > MAX_HTTP_TYPING_BYTES {
return Err(IngestError::Rejected(format!(
"invalid: typing indicator too large (max {MAX_HTTP_TYPING_BYTES} bytes of content and tags)"
"invalid: typing indicator too large (max {MAX_HTTP_TYPING_BYTES} bytes serialized)"
)));
}
let event_clone = event.clone();

View File

@@ -2390,6 +2390,8 @@ async fn ingest_event_inner(
));
}
// Typing indicators (kind:20002) never reach this gate: the HTTP bridge
// routes them to `publish_http_typing` before ingest.
if auth.is_http() && (kind_u32 == KIND_GIFT_WRAP || kind_u32 == KIND_PRESENCE_UPDATE) {
return Err(IngestError::Rejected(format!(
"invalid: kind {kind_u32} is only accepted via WebSocket"

View File

@@ -122,7 +122,7 @@ buzz messages send --channel <UUID> \
## Gotchas
1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order.
2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay rejects ephemeral kinds over HTTP. Will fail until WebSocket support is added.
2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay accepts presence only over WebSocket (typing, kind:20002, is the one ephemeral kind HTTP accepts). Will fail until WebSocket support is added.
3. **`workflow runs` always returns `[]`** — run history lives in the relay's database, not as Nostr events.
4. **`dms open` returns `dm_id`** — use this value as `--channel` for subsequent `messages send/get` commands on that DM.
5. **Content max 65,536 bytes** (exit 1 if exceeded). Diffs auto-truncate at 61,440 bytes at a hunk boundary.