Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,20 @@ async fn query_events_authed(
// depth_limit, feed_types) that nostr::Filter silently drops.
let raw_filters: Vec<Value> = 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<nostr::Filter> = raw_filters
.iter()
.map(|v| serde_json::from_value(v.clone()))
Expand Down Expand Up @@ -1412,6 +1426,19 @@ async fn count_events_authed(

let filters: Vec<nostr::Filter> = 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();
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-relay/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down