From 82c7b51539047cb1f1fd020a844852257fd2bea3 Mon Sep 17 00:00:00 2001
From: Jordan Mecom <jm@squareup.com>
Date: Thu, 9 Jul 2026 13:14:46 -0400
Subject: [PATCH 1/2] Fix huddle audio WebSocket admission limits

---
 Cargo.lock                             |   1 +
 crates/buzz-relay/Cargo.toml           |   1 +
 crates/buzz-relay/src/audio/handler.rs | 133 ++++++++++++++++++++++++-
 3 files changed, 133 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 5175c5c66d..0afe1e2773 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1165,6 +1165,7 @@ dependencies = [
  "tempfile",
  "thiserror 2.0.18",
  "tokio",
+ "tokio-tungstenite 0.29.0",
  "tokio-util",
  "tower",
  "tower-http",
diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml
index 31eac113a1..4ef5708fe4 100644
--- a/crates/buzz-relay/Cargo.toml
+++ b/crates/buzz-relay/Cargo.toml
@@ -88,3 +88,4 @@ mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag
 buzz-core = { workspace = true, features = ["test-utils"] }
 buzz-auth = { workspace = true, features = ["dev"] }
 reqwest = { workspace = true }
+tokio-tungstenite = { workspace = true }
diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs
index 5fcc79b17b..16cd56209c 100644
--- a/crates/buzz-relay/src/audio/handler.rs
+++ b/crates/buzz-relay/src/audio/handler.rs
@@ -25,7 +25,7 @@ use bytes::Bytes;
 use futures_util::{SinkExt, StreamExt};
 use nostr::{EventBuilder, Kind, Tag};
 use serde::Deserialize;
-use tokio::sync::mpsc;
+use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
 use tokio_util::sync::CancellationToken;
 use tracing::{debug, error, info, warn};
 use uuid::Uuid;
@@ -46,6 +46,11 @@ const MAX_AUDIO_FRAME_BYTES: usize = 4096;
 /// Maximum text frame size: 8 KB bounds auth/control JSON parsing.
 const MAX_TEXT_FRAME_BYTES: usize = 8192;
 
+/// Parser-level cap for this route. Text auth/control frames are the largest
+/// message type audio accepts; binary Opus frames are bounded more tightly
+/// after parsing.
+const MAX_WEBSOCKET_MESSAGE_BYTES: usize = MAX_TEXT_FRAME_BYTES;
+
 /// Heartbeat interval.
 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
 
@@ -81,7 +86,36 @@ pub async fn ws_audio_handler(
                 .into_response();
         }
     };
-    ws.on_upgrade(move |socket| handle_audio_connection(socket, state, tenant, channel_id))
+
+    let permit = match acquire_audio_connection_permit(&state.conn_semaphore) {
+        Some(permit) => permit,
+        None => {
+            warn!(channel_id = %channel_id, "Connection limit reached, rejecting audio WebSocket");
+            return (
+                StatusCode::SERVICE_UNAVAILABLE,
+                "relay: connection limit reached",
+            )
+                .into_response();
+        }
+    };
+
+    // Keep the parser boundary at the largest message this route accepts. The
+    // checks in the receive loop still distinguish text from binary policy, but
+    // they run after tungstenite has assembled a message.
+    limit_audio_websocket(ws).on_upgrade(move |socket| {
+        handle_audio_connection(socket, state, tenant, channel_id, permit)
+    })
+}
+
+fn acquire_audio_connection_permit(
+    conn_semaphore: &Arc<Semaphore>,
+) -> Option<OwnedSemaphorePermit> {
+    Arc::clone(conn_semaphore).try_acquire_owned().ok()
+}
+
+fn limit_audio_websocket<F>(ws: WebSocketUpgrade<F>) -> WebSocketUpgrade<F> {
+    ws.max_message_size(MAX_WEBSOCKET_MESSAGE_BYTES)
+        .max_frame_size(MAX_WEBSOCKET_MESSAGE_BYTES)
 }
 
 /// Highest huddle audio protocol version this relay understands. Clients are
@@ -112,6 +146,7 @@ async fn handle_audio_connection(
     state: Arc<AppState>,
     tenant: TenantContext,
     channel_id: Uuid,
+    _permit: OwnedSemaphorePermit,
 ) {
     let cancel = CancellationToken::new();
     let community_id = tenant.community();
@@ -1299,3 +1334,97 @@ async fn emit_participant_event(
         );
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Mutex;
+
+    use axum::{routing::get, Router};
+    use futures_util::SinkExt;
+    use tokio::net::TcpListener;
+    use tokio::sync::oneshot;
+    use tokio_tungstenite::{connect_async, tungstenite::Message};
+
+    use super::*;
+
+    #[test]
+    fn audio_connection_permits_share_the_global_websocket_budget() {
+        let semaphore = Arc::new(Semaphore::new(1));
+        let first = acquire_audio_connection_permit(&semaphore).expect("first permit");
+
+        assert!(
+            acquire_audio_connection_permit(&semaphore).is_none(),
+            "audio connections must stop when the global WebSocket budget is exhausted"
+        );
+
+        drop(first);
+        assert!(
+            acquire_audio_connection_permit(&semaphore).is_some(),
+            "dropping an audio connection must return its global permit"
+        );
+    }
+
+    async fn handler_receives_message_of_size(size: usize) -> bool {
+        let (received_tx, received_rx) = oneshot::channel();
+        let received_tx = Arc::new(Mutex::new(Some(received_tx)));
+        let app = Router::new().route(
+            "/",
+            get({
+                let received_tx = Arc::clone(&received_tx);
+                move |ws: WebSocketUpgrade| {
+                    let received_tx = Arc::clone(&received_tx);
+                    async move {
+                        limit_audio_websocket(ws).on_upgrade(move |mut socket| async move {
+                            let received = matches!(socket.recv().await, Some(Ok(_)));
+                            if let Some(tx) =
+                                received_tx.lock().expect("result lock poisoned").take()
+                            {
+                                let _ = tx.send(received);
+                            }
+                        })
+                    }
+                }
+            }),
+        );
+
+        let listener = TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("bind test WebSocket listener");
+        let addr = listener.local_addr().expect("test listener address");
+        let server = tokio::spawn(async move {
+            axum::serve(listener, app)
+                .await
+                .expect("test WebSocket server");
+        });
+
+        let (mut client, _) = connect_async(format!("ws://{addr}/"))
+            .await
+            .expect("connect test WebSocket client");
+        client
+            .send(Message::Text("x".repeat(size).into()))
+            .await
+            .expect("send test WebSocket message");
+
+        let received = tokio::time::timeout(Duration::from_secs(2), received_rx)
+            .await
+            .expect("server should process the test message")
+            .expect("server should report whether it received the message");
+
+        server.abort();
+        let _ = server.await;
+
+        received
+    }
+
+    #[tokio::test]
+    async fn audio_websocket_parser_rejects_oversized_messages_before_handler_reads_them() {
+        assert!(
+            handler_receives_message_of_size(MAX_WEBSOCKET_MESSAGE_BYTES).await,
+            "messages at the audio route limit should still reach the handler"
+        );
+        assert!(
+            !handler_receives_message_of_size(MAX_WEBSOCKET_MESSAGE_BYTES + 1).await,
+            "oversized messages must be rejected by the WebSocket parser before the handler sees them"
+        );
+    }
+}

From 80fdd621017254b06c52b00821fd5f2fd00ec53d Mon Sep 17 00:00:00 2001
From: Jordan Mecom <jm@squareup.com>
Date: Thu, 9 Jul 2026 14:10:04 -0400
Subject: [PATCH 2/2] Cap main relay WebSocket parser messages

---
 crates/buzz-relay/src/router.rs | 78 ++++++++++++++++++++++++++++++++-
 1 file changed, 76 insertions(+), 2 deletions(-)

diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index ed063398d3..0017e0d8d5 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -240,8 +240,9 @@ async fn nip11_or_ws_handler(
         }
     };
 
+    let max_frame_bytes = state.config.max_frame_bytes;
     match WebSocketUpgrade::from_request(req, &state).await {
-        Ok(ws) => ws
+        Ok(ws) => limit_relay_websocket(ws, max_frame_bytes)
             .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
             .into_response(),
         Err(_) => {
@@ -262,6 +263,16 @@ async fn nip11_or_ws_handler(
     }
 }
 
+fn limit_relay_websocket<F>(
+    ws: WebSocketUpgrade<F>,
+    max_frame_bytes: usize,
+) -> WebSocketUpgrade<F> {
+    // recv_loop keeps the application-level check as defense in depth, but
+    // parser limits must be set before tungstenite assembles the message.
+    ws.max_message_size(max_frame_bytes)
+        .max_frame_size(max_frame_bytes)
+}
+
 async fn health_handler() -> impl IntoResponse {
     (StatusCode::OK, "ok")
 }
@@ -354,7 +365,13 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer {
 
 #[cfg(test)]
 mod tests {
-    use super::{is_git_web_gui_path, is_invite_landing_path, should_serve_spa};
+    use axum::{routing::get, Router};
+    use futures_util::SinkExt;
+    use tokio::net::TcpListener;
+    use tokio::sync::mpsc;
+    use tokio_tungstenite::{connect_async, tungstenite::Message};
+
+    use super::*;
 
     #[test]
     fn invite_landing_path_requires_exactly_one_nonempty_code_segment() {
@@ -385,4 +402,61 @@ mod tests {
         assert!(should_serve_spa("/repos/example", true));
         assert!(!should_serve_spa("/arbitrary", true));
     }
+
+    async fn handler_receives_message_with_limit(limit: usize, size: usize) -> bool {
+        let (received_tx, mut received_rx) = mpsc::unbounded_channel();
+        let app = Router::new().route(
+            "/",
+            get(move |ws: WebSocketUpgrade| {
+                let received_tx = received_tx.clone();
+                async move {
+                    limit_relay_websocket(ws, limit).on_upgrade(move |mut socket| async move {
+                        let _ = received_tx.send(matches!(socket.recv().await, Some(Ok(_))));
+                    })
+                }
+            }),
+        );
+
+        let listener = TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("bind test WebSocket listener");
+        let addr = listener.local_addr().expect("test listener address");
+        let server = tokio::spawn(async move {
+            axum::serve(listener, app)
+                .await
+                .expect("test WebSocket server");
+        });
+
+        let (mut client, _) = connect_async(format!("ws://{addr}/"))
+            .await
+            .expect("connect test WebSocket client");
+        client
+            .send(Message::Text("x".repeat(size).into()))
+            .await
+            .expect("send test WebSocket message");
+
+        let received = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv())
+            .await
+            .expect("server should process the test message")
+            .expect("server should report whether it received the message");
+
+        server.abort();
+        let _ = server.await;
+
+        received
+    }
+
+    #[tokio::test]
+    async fn relay_websocket_parser_rejects_oversized_messages_before_handler_reads_them() {
+        let limit = 64;
+
+        assert!(
+            handler_receives_message_with_limit(limit, limit).await,
+            "messages at the relay limit should still reach the handler"
+        );
+        assert!(
+            !handler_receives_message_with_limit(limit, limit + 1).await,
+            "oversized messages must be rejected by the WebSocket parser before the handler sees them"
+        );
+    }
 }
