diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index e3458d11bc..2b76c2e0a1 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -402,6 +402,12 @@ pub async fn query_events( .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; if filters.iter().any(|f| f.search.is_some()) { + if has_mixed_search_filters(&filters) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "mixed search and non-search filters not supported", + )); + } return handle_bridge_search( &state, &filters, @@ -800,6 +806,10 @@ pub async fn count_events( Ok(Json(serde_json::json!({ "count": total }))) } +fn has_mixed_search_filters(filters: &[nostr::Filter]) -> bool { + filters.iter().any(|f| f.search.is_some()) && filters.iter().any(|f| f.search.is_none()) +} + /// Decide whether a search hit should be returned to the caller. /// /// Mirrors the WS NIP-50 path's post-filter step in `handlers/req.rs`: @@ -1228,6 +1238,36 @@ mod tests { .to_bytes() } + #[test] + fn bridge_detects_mixed_search_and_non_search_filters() { + let filters = vec![ + nostr::Filter::new().search("hello"), + nostr::Filter::new().kind(Kind::TextNote), + ]; + + assert!(has_mixed_search_filters(&filters)); + } + + #[test] + fn bridge_accepts_all_search_filters() { + let filters = vec![ + nostr::Filter::new().search("hello"), + nostr::Filter::new().search("world"), + ]; + + assert!(!has_mixed_search_filters(&filters)); + } + + #[test] + fn bridge_accepts_all_non_search_filters() { + let filters = vec![ + nostr::Filter::new().kind(Kind::TextNote), + nostr::Filter::new().kind(Kind::Metadata), + ]; + + assert!(!has_mixed_search_filters(&filters)); + } + /// Attack 3 proof: two stateless relay pods sharing Redis must share one /// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's /// replay of the same event id in the same community is rejected. The same diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index b6be8a0cc5..ad0d857f86 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -113,6 +113,33 @@ pub struct SearchResult { const PER_PAGE_MAX: u32 = 500; const PER_PAGE_DEFAULT: u32 = 100; +/// Hard cap on search text handed to `websearch_to_tsquery`. This keeps a +/// single request from spending unbounded parser CPU/memory while still allowing +/// far longer queries than the desktop UI normally emits. +const SEARCH_TEXT_MAX_CHARS: usize = 4096; +/// Search pages are currently server-generated (WS uses 1..=MAX_SEARCH_PAGES, +/// bridge uses page 1), but clamp here too so a future caller cannot accidentally +/// wire untrusted input into a multi-trillion-row OFFSET. +const PAGE_MAX: u32 = 1000; + +fn normalized_search_text(q: &str) -> Option { + let trimmed = q.trim(); + if trimmed.is_empty() { + return None; + } + + let mut cleaned = String::with_capacity(trimmed.len().min(SEARCH_TEXT_MAX_CHARS)); + for ch in trimmed.chars().take(SEARCH_TEXT_MAX_CHARS) { + cleaned.push(if ch == '\0' { ' ' } else { ch }); + } + + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + None + } else { + Some(cleaned.to_string()) + } +} /// Execute a community-scoped FTS query. /// @@ -133,13 +160,12 @@ const PER_PAGE_DEFAULT: u32 = 100; /// `community_id = $ctx` is the first predicate and is non-negotiable. There /// is no code path through this function that omits it. pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { - let trimmed = query.q.trim(); - if trimmed.is_empty() { + let Some(search_text) = normalized_search_text(&query.q) else { return Ok(SearchResult { hits: Vec::new(), - page: query.page.max(1), + page: query.page.max(1).min(PAGE_MAX), }); - } + }; let per_page = query.per_page.clamp(1, PER_PAGE_MAX); let per_page_actual = if query.per_page == 0 { @@ -147,7 +173,7 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( @@ -156,7 +182,7 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result Result