From a427b8e9ed526be8ace5adf21aaa0b51f5197a86 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Thu, 6 Aug 2026 06:04:37 +0530 Subject: [PATCH] fix(relay): cap filter count on the HTTP bridge /query and /count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket REQ/COUNT door rejects filter lists longer than `MAX_FILTERS_PER_REQ` (10) — the `max_filters` the relay advertises in NIP-11. The HTTP bridge reaches the same `query_events`/`count_events` machinery but parsed the body's filter list with no cap, so a single ~1 MB request expands into ~10^5 independent DB queries (or unbounded COUNT aggregate scans) against the shared pool — a resource-amplification DoS triggerable by any authenticated participant, one request per rate-limit tick. Enforce the same advertised cap at both bridge parse sites, before any filter becomes a query. The constant is now `pub(crate)` so the WS door and the bridge share one source of truth. Legitimate clients send 1-2 filters per query, well under the advertised limit. Adds `advertised_max_filters_matches_the_enforced_cap` (nip11.rs) pinning that the advertised `max_filters` equals the enforced constant, so the NIP-11 document, the WS door, and the bridge can't drift apart. Signed-off-by: Shani Singh --- crates/buzz-relay/src/api/bridge.rs | 27 +++++++++++++++++++++++++++ crates/buzz-relay/src/nip11.rs | 13 +++++++++++++ crates/buzz-relay/src/protocol.rs | 5 ++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..11117b615d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -973,6 +973,20 @@ async fn query_events_authed( // depth_limit, feed_types) that nostr::Filter silently drops. let raw_filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + // Enforce the same NIP-11 `max_filters` cap the WebSocket REQ door does + // (protocol.rs). Each filter becomes an independent DB query, so an + // unbounded list turns one 1 MB request into hundreds of thousands of + // queries against the shared pool. + if raw_filters.len() > crate::protocol::MAX_FILTERS_PER_REQ { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "too many filters: {} (max {})", + raw_filters.len(), + crate::protocol::MAX_FILTERS_PER_REQ + ), + )); + } let filters: Vec = raw_filters .iter() .map(|v| serde_json::from_value(v.clone())) @@ -1412,6 +1426,19 @@ async fn count_events_authed( let filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + // Same NIP-11 `max_filters` cap as the WS COUNT door and `/query` — a + // non-pushable COUNT filter runs an unbounded aggregate scan, so an + // uncapped list is an even cheaper amplification than `/query`. + if filters.len() > crate::protocol::MAX_FILTERS_PER_REQ { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "too many filters: {} (max {})", + filters.len(), + crate::protocol::MAX_FILTERS_PER_REQ + ), + )); + } // P-gated kinds enforcement — same as WS REQ and /query. let authed_pubkey_hex = pubkey.to_hex(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7ba..eff4e6f285 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -457,6 +457,19 @@ mod tests { assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES).auth_required); } + #[test] + fn advertised_max_filters_matches_the_enforced_cap() { + // The relay must never advertise a `max_filters` it does not enforce. + // Both doors that accept NIP-01 filter lists — the WebSocket REQ/COUNT + // handler and the HTTP bridge `/query`/`/count` — reject lists longer + // than `crate::protocol::MAX_FILTERS_PER_REQ`, so the advertised value + // must equal it. Guards against the three drifting apart. + assert_eq!( + relay_limitation(DEFAULT_MAX_FRAME_BYTES).max_filters, + Some(crate::protocol::MAX_FILTERS_PER_REQ as u32), + ); + } + #[test] fn max_message_length_uses_configured_frame_limit() { let info = RelayInfo::build(None, None, false, 262_144, None); diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index 89b4810fd5..a4b8d6558c 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -9,7 +9,10 @@ use crate::error::{RelayError, Result}; const MAX_SUB_ID_LENGTH: usize = 256; /// NIP-11 advertised limit: REQ messages with more filters than this are rejected. -const MAX_FILTERS_PER_REQ: usize = 10; +/// +/// `pub(crate)` so the HTTP bridge (`/query`, `/count`) enforces the same +/// advertised cap the WebSocket door does — both reach the same query machinery. +pub(crate) const MAX_FILTERS_PER_REQ: usize = 10; /// A message sent by a NIP-01 client to the relay. #[derive(Debug, Clone)]