@@ -266,7 +266,8 @@ pub async fn query_events(
266266
267267 // ── NIP-50 search: route to Typesense if any filter has a `search` field ──
268268 if filters. iter ( ) . any ( |f| f. search . is_some ( ) ) {
269- return handle_bridge_search ( & state, & filters, & accessible_channels) . await ;
269+ return handle_bridge_search ( & state, & filters, & accessible_channels, & authed_pubkey_hex)
270+ . await ;
270271 }
271272
272273 // ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ──
@@ -438,6 +439,14 @@ pub async fn query_events(
438439 if !sprout_core:: filter:: filters_match ( std:: slice:: from_ref ( filter) , & se) {
439440 continue ;
440441 }
442+ // Result-level read auth: never hand a viewer-private snapshot
443+ // (kind:30622) to anyone but its owner, even via kindless `ids`.
444+ if !sprout_core:: filter:: reader_authorized_for_event (
445+ & se. event ,
446+ & authed_pubkey_hex,
447+ ) {
448+ continue ;
449+ }
441450 if let Ok ( v) = serde_json:: to_value ( & se. event ) {
442451 events. push ( v) ;
443452 }
@@ -596,6 +605,7 @@ fn search_hit_accepted(
596605 filter : & nostr:: Filter ,
597606 stored : & sprout_core:: StoredEvent ,
598607 accessible_channels : & [ uuid:: Uuid ] ,
608+ reader_pubkey_hex : & str ,
599609) -> bool {
600610 if !sprout_core:: filter:: filters_match ( std:: slice:: from_ref ( filter) , stored) {
601611 return false ;
@@ -605,6 +615,9 @@ fn search_hit_accepted(
605615 return false ;
606616 }
607617 }
618+ if !sprout_core:: filter:: reader_authorized_for_event ( & stored. event , reader_pubkey_hex) {
619+ return false ;
620+ }
608621 true
609622}
610623
@@ -614,6 +627,7 @@ async fn handle_bridge_search(
614627 state : & AppState ,
615628 filters : & [ nostr:: Filter ] ,
616629 accessible_channels : & [ uuid:: Uuid ] ,
630+ reader_pubkey_hex : & str ,
617631) -> Result < Json < Value > , ( StatusCode , Json < Value > ) > {
618632 // Bridge always includes global (non-channel) events — same as WS with full scopes.
619633 let channel_scope = match crate :: handlers:: req:: build_search_channel_scope_filter (
@@ -727,7 +741,7 @@ async fn handle_bridge_search(
727741 Some ( ev) => ev,
728742 None => continue ,
729743 } ;
730- if !search_hit_accepted ( filter, stored, accessible_channels) {
744+ if !search_hit_accepted ( filter, stored, accessible_channels, reader_pubkey_hex ) {
731745 continue ;
732746 }
733747 // Dedup across filters.
@@ -1004,12 +1018,14 @@ mod tests {
10041018 . kind ( Kind :: Custom ( 30174 ) )
10051019 . custom_tags ( p_tag, [ & owner_a] ) ;
10061020
1021+ // 30174 is not owner-gated, so any reader hex is fine here.
1022+ let reader = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
10071023 assert ! (
1008- search_hit_accepted( & filter, & env_for_a, & [ ] ) ,
1024+ search_hit_accepted( & filter, & env_for_a, & [ ] , & reader ) ,
10091025 "envelope addressed to owner_a must be returned"
10101026 ) ;
10111027 assert ! (
1012- !search_hit_accepted( & filter, & env_for_b, & [ ] ) ,
1028+ !search_hit_accepted( & filter, & env_for_b, & [ ] , & reader ) ,
10131029 "envelope addressed to owner_b must NOT be returned for a #p=[owner_a] search"
10141030 ) ;
10151031 }
@@ -1031,9 +1047,10 @@ mod tests {
10311047 . kind ( Kind :: Custom ( 30174 ) )
10321048 . author ( agent_a. public_key ( ) ) ;
10331049
1034- assert ! ( search_hit_accepted( & filter, & env_a, & [ ] ) ) ;
1050+ let reader = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
1051+ assert ! ( search_hit_accepted( & filter, & env_a, & [ ] , & reader) ) ;
10351052 assert ! (
1036- !search_hit_accepted( & filter, & env_b, & [ ] ) ,
1053+ !search_hit_accepted( & filter, & env_b, & [ ] , & reader ) ,
10371054 "authors=[agent_a] search must not return events authored by agent_b"
10381055 ) ;
10391056 }
@@ -1053,12 +1070,13 @@ mod tests {
10531070 . kind ( Kind :: Custom ( 30174 ) )
10541071 . custom_tags ( p_tag, [ & owner] ) ;
10551072
1073+ let reader = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
10561074 assert ! (
1057- !search_hit_accepted( & filter, & stored, & [ ] ) ,
1075+ !search_hit_accepted( & filter, & stored, & [ ] , & reader ) ,
10581076 "channel-scoped hit must be rejected when caller has no channel access"
10591077 ) ;
10601078 assert ! (
1061- search_hit_accepted( & filter, & stored, & [ scoped_channel] ) ,
1079+ search_hit_accepted( & filter, & stored, & [ scoped_channel] , & reader ) ,
10621080 "channel-scoped hit must be accepted when caller has access to that channel"
10631081 ) ;
10641082 }
@@ -1216,4 +1234,45 @@ mod tests {
12161234 se. channel_id = Some ( ch) ;
12171235 assert ! ( !event_in_accessible_channel( & se, & [ other] ) ) ;
12181236 }
1237+
1238+ /// NIP-DV regression: a relay-signed kind:30622 snapshot must not leak via
1239+ /// search through a kindless `ids:[snapshot_id]` filter that carries no #p.
1240+ /// `filters_match` passes (id matches), channel check passes (channel_id =
1241+ /// None), so only the result-level `reader_authorized_for_event` check
1242+ /// stands between a third party and the owner's private hide set.
1243+ #[ test]
1244+ fn search_hit_rejects_dm_visibility_for_kindless_ids_third_party ( ) {
1245+ let relay = Keys :: generate ( ) ;
1246+ let viewer = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
1247+ let third_party = Keys :: generate ( ) . public_key ( ) . to_hex ( ) ;
1248+
1249+ let d_tag = Tag :: custom (
1250+ nostr:: TagKind :: SingleLetter ( SingleLetterTag :: lowercase ( Alphabet :: D ) ) ,
1251+ [ & viewer] ,
1252+ ) ;
1253+ let p_tag = Tag :: custom (
1254+ nostr:: TagKind :: SingleLetter ( SingleLetterTag :: lowercase ( Alphabet :: P ) ) ,
1255+ [ & viewer] ,
1256+ ) ;
1257+ let ev = EventBuilder :: new (
1258+ Kind :: Custom ( sprout_core:: kind:: KIND_DM_VISIBILITY as u16 ) ,
1259+ "" ,
1260+ )
1261+ . tags ( [ d_tag, p_tag] )
1262+ . sign_with_keys ( & relay)
1263+ . expect ( "sign snapshot" ) ;
1264+ let stored = sprout_core:: StoredEvent :: new ( ev. clone ( ) , None ) ;
1265+
1266+ // Kindless filter — the exact bypass shape: no #p, just the id.
1267+ let filter = nostr:: Filter :: new ( ) . id ( ev. id ) ;
1268+
1269+ assert ! (
1270+ !search_hit_accepted( & filter, & stored, & [ ] , & third_party) ,
1271+ "third party must not receive a DM-visibility snapshot via kindless ids search"
1272+ ) ;
1273+ assert ! (
1274+ search_hit_accepted( & filter, & stored, & [ ] , & viewer) ,
1275+ "owner must still receive their own snapshot"
1276+ ) ;
1277+ }
12191278}
0 commit comments