From c8ab2c9c294ae9401ff542d0eecc6606b7908412 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 21:51:57 +0000 Subject: feat(config): add event blacklist to block all events from specific authors Adds NGIT_EVENT_BLACKLIST option for blocking all events from specific npubs, taking precedence over all other validation to enable comprehensive moderation without affecting curation policy. Key features: - Simple npub-only format: ,,... - Checked FIRST before any other validation (including repository blacklist) - Blocks ALL event types (announcements, state events, PRs, comments, etc.) - Events never reach relay storage or purgatory - Specific rejection reason for operator debugging Implementation: - Add EventBlacklistConfig struct with check() method - Add NGIT_EVENT_BLACKLIST config option and event_blacklist_config() method - Add config field to PolicyContext for policy access - Add check_event_blacklist() to Nip34WritePolicy - Check event blacklist first in admit_event() method (before any other validation) - 4 new unit tests covering all blacklist behavior Configuration synced across all four sources: - src/config.rs: Core implementation with EventBlacklistConfig - .env.example: Comprehensive documentation with examples - docs/reference/configuration.md: Complete reference documentation - nix/module.nix: NixOS module option with environment mapping README updates: - Add comprehensive "Curation & Moderation" section - Document repository whitelists (GRASP-01 and GRASP-05 modes) - Document repository and event blacklists with precedence order - Add configuration table for all curation/moderation settings - Provide real-world examples for different relay configurations Testing: - 4 new tests for event blacklist functionality - All 336 library tests passing - All 64 integration tests passing - All 38 filter support tests passing Verification: - Repository blacklist confirmed to apply to sync (uses same admit_event flow) - Sync events validated through process_event_static -> write_policy.admit_event Use cases: - Block spam/abusive users completely - Prevent malicious actors from submitting any events - Temporary blocks for investigation - Moderation without affecting whitelist curation policy --- src/config.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ src/nostr/builder.rs | 32 +++++++++++++- src/nostr/policy/mod.rs | 4 ++ 3 files changed, 145 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 5f8cbca..a5e4344 100644 --- a/src/config.rs +++ b/src/config.rs @@ -244,6 +244,42 @@ impl Default for BlacklistConfig { } } +/// Event blacklist configuration for blocking events by author npub +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventBlacklistConfig { + /// Blacklisted npubs - events from these authors are rejected + /// + /// If empty, no events are blacklisted by author. + /// Applies to ALL event types, preventing events from reaching both the relay and purgatory. + pub blacklisted_npubs: Vec, +} + +impl EventBlacklistConfig { + /// Check if event blacklist is enabled (non-empty blacklist) + pub fn enabled(&self) -> bool { + !self.blacklisted_npubs.is_empty() + } + + /// Check if an event author is blacklisted + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + pub fn check(&self, npub: &str) -> Option { + if self.blacklisted_npubs.contains(&npub.to_string()) { + Some(format!("Event author {} is blacklisted", npub)) + } else { + None + } + } +} + +impl Default for EventBlacklistConfig { + fn default() -> Self { + Self { + blacklisted_npubs: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -428,6 +464,11 @@ pub struct Config { /// Blacklist takes precedence over all whitelists (archive and repository) #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] pub repository_blacklist: String, + + /// Event blacklist: comma-separated list of npubs whose events are rejected + /// All events from these authors are blocked from both relay storage and purgatory + #[arg(long, env = "NGIT_EVENT_BLACKLIST", default_value = "")] + pub event_blacklist: String, } impl Config { @@ -612,6 +653,20 @@ impl Config { BlacklistConfig { blacklist } } + /// Get parsed event blacklist configuration + /// + /// This method assumes config has been validated - call Config::validate() first! + pub fn event_blacklist_config(&self) -> EventBlacklistConfig { + let blacklisted_npubs: Vec = self + .event_blacklist + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + EventBlacklistConfig { blacklisted_npubs } + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -647,6 +702,7 @@ impl Config { archive_read_only: None, repository_whitelist: String::new(), repository_blacklist: String::new(), + event_blacklist: String::new(), } } } @@ -1248,4 +1304,58 @@ mod tests { let result = config.check(&test_npub, "allowed-repo"); assert!(result.is_none()); } + + #[test] + fn test_event_blacklist_config_parsing() { + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let npub1 = keys1.public_key().to_bech32().unwrap(); + let npub2 = keys2.public_key().to_bech32().unwrap(); + let config = Config { + event_blacklist: format!("{},{}", npub1, npub2), + ..Config::for_testing() + }; + let event_blacklist_config = config.event_blacklist_config(); + assert_eq!(event_blacklist_config.blacklisted_npubs.len(), 2); + assert!(event_blacklist_config.enabled()); + assert!(event_blacklist_config.blacklisted_npubs.contains(&npub1)); + assert!(event_blacklist_config.blacklisted_npubs.contains(&npub2)); + } + + #[test] + fn test_event_blacklist_config_empty() { + let config = Config::for_testing(); + let event_blacklist_config = config.event_blacklist_config(); + assert!(event_blacklist_config.blacklisted_npubs.is_empty()); + assert!(!event_blacklist_config.enabled()); + } + + #[test] + fn test_event_blacklist_check_blacklisted() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = EventBlacklistConfig { + blacklisted_npubs: vec![test_npub.clone()], + }; + + let result = config.check(&test_npub); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("author")); + assert!(reason.contains(&test_npub)); + } + + #[test] + fn test_event_blacklist_check_not_blacklisted() { + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let banned_npub = keys1.public_key().to_bech32().unwrap(); + let allowed_npub = keys2.public_key().to_bech32().unwrap(); + let config = EventBlacklistConfig { + blacklisted_npubs: vec![banned_npub], + }; + + let result = config.check(&allowed_npub); + assert!(result.is_none()); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 9819e37..c2de1df 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -56,7 +56,13 @@ impl Nip34WritePolicy { purgatory: std::sync::Arc, config: crate::config::Config, ) -> Self { - let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory); + let ctx = PolicyContext::new( + &config.domain, + database, + git_data_path, + purgatory, + config.clone(), + ); Self { announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), state_policy: StatePolicy::new(ctx.clone()), @@ -66,6 +72,19 @@ impl Nip34WritePolicy { } } + /// Check if an event author is blacklisted + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + fn check_event_blacklist(&self, event: &Event) -> Option { + let event_blacklist = self.ctx.config.event_blacklist_config(); + if !event_blacklist.enabled() { + return None; + } + + let npub = event.pubkey.to_bech32().ok()?; + event_blacklist.check(&npub) + } + /// Get a reference to the purgatory for read-only access pub fn purgatory(&self) -> &std::sync::Arc { &self.ctx.purgatory @@ -474,6 +493,17 @@ impl WritePolicy for Nip34WritePolicy { addr: &'a SocketAddr, ) -> BoxedFuture<'a, WritePolicyResult> { Box::pin(async move { + // Check event blacklist FIRST - it overrides everything + if let Some(reason) = self.check_event_blacklist(event) { + tracing::debug!( + event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()), + author = %event.pubkey.to_hex(), + reason = %reason, + "Rejected event from blacklisted author" + ); + return WritePolicyResult::reject(reason); + } + // Detect if this is a synced event (from proactive sync) vs user-submitted // Sync uses localhost:0 as a dummy address let is_synced = addr.ip().is_loopback() && addr.port() == 0; diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index dc023a9..1566b6c 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs @@ -32,6 +32,8 @@ pub struct PolicyContext { pub purgatory: Arc, /// Local relay for notifying WebSocket subscribers (set after relay creation) pub local_relay: Arc>>, + /// Configuration reference for policy settings (includes blacklists) + pub config: crate::config::Config, } impl PolicyContext { @@ -40,6 +42,7 @@ impl PolicyContext { database: SharedDatabase, git_data_path: impl Into, purgatory: Arc, + config: crate::config::Config, ) -> Self { Self { domain: domain.into(), @@ -47,6 +50,7 @@ impl PolicyContext { git_data_path: git_data_path.into(), purgatory, local_relay: Arc::new(std::sync::RwLock::new(None)), + config, } } -- cgit v1.2.3