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: <npub>,<npub>,...
- 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
This commit is contained in:
DanConwayDev
2026-01-12 21:51:57 +00:00
parent 70c577f10b
commit c8ab2c9c29
7 changed files with 374 additions and 3 deletions
+24 -1
View File
@@ -254,4 +254,27 @@
# NGIT_REPOSITORY_BLACKLIST=npub1spam...
# NGIT_REPOSITORY_BLACKLIST=npub1alice.../bad-repo
# NGIT_REPOSITORY_BLACKLIST=malware-repo,spam-repo
# NGIT_REPOSITORY_BLACKLIST=
# NGIT_REPOSITORY_BLACKLIST=
# ============================================================================
# EVENT BLACKLIST
# ============================================================================
# Blacklist events from specific authors (npubs)
# Comma-separated list of npubs whose events are rejected
# ALL events from these authors are blocked from both relay storage and purgatory
#
# Event blacklist takes precedence over ALL other validation:
# - Blacklisted events are rejected before any other policy checks
# - Applies to announcements, state events, PRs, and all other event types
# - Events never reach purgatory (rejected immediately)
#
# Rejection reason:
# - "Event author <npub> is blacklisted"
#
# CLI: --event-blacklist <list>
# Default: (empty - no events are blacklisted by author)
# Examples:
# NGIT_EVENT_BLACKLIST=npub1spam...
# NGIT_EVENT_BLACKLIST=npub1spam...,npub1abuser...
# NGIT_EVENT_BLACKLIST=
+99 -1
View File
@@ -36,7 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15
- **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself
- **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation
- **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization
- **Repository Whitelist/Blacklist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) and blacklist (overrides all whitelists)
- **Flexible Curation & Moderation**: Repository whitelists (GRASP-01 mode), repository blacklists (moderation), and event blacklists (author blocking)
- **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization
- **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE
- **Live & Historic Sync**: Real-time event streaming plus catch-up for past events
@@ -150,6 +150,93 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md)
## Curation & Moderation
ngit-grasp provides flexible tools for both curation (repository selection) and moderation (blocking spam/abuse):
### Repository Whitelists (Curation)
Control which repositories your relay accepts via two independent whitelist modes:
**Repository Whitelist (GRASP-01 Mode):**
- Only accept announcements that **both** list your service AND match the whitelist
- Three formats: `<npub>`, `<npub>/<identifier>`, `<identifier>`
- Environment: `NGIT_REPOSITORY_WHITELIST=npub1alice...,bitcoin-core`
- Use case: Curated relay accepting specific projects/developers
**Archive Whitelist (GRASP-05 Mode):**
- Accept announcements matching the whitelist **even if they don't list your service**
- Same three formats as repository whitelist
- Environment: `NGIT_ARCHIVE_WHITELIST=npub1satoshi...,linux`
- Use case: Backup/mirror relay for critical projects
- Default: Read-only mode (`NGIT_ARCHIVE_READ_ONLY=true`)
Both whitelists support flexible matching:
```bash
# Accept all repos from specific developer
NGIT_REPOSITORY_WHITELIST=npub1alice...
# Accept specific repository
NGIT_REPOSITORY_WHITELIST=npub1alice.../my-project
# Accept repos with specific identifier (any author)
NGIT_REPOSITORY_WHITELIST=bitcoin-core
```
### Blacklists (Moderation)
Block unwanted content without affecting your curation policy:
**Repository Blacklist:**
- Block specific repositories/developers/identifiers
- **Takes precedence over ALL whitelists** (checked first)
- Three formats: `<npub>`, `<npub>/<identifier>`, `<identifier>`
- Environment: `NGIT_REPOSITORY_BLACKLIST=npub1spam...,malware-repo`
- Use case: Block spam/malware repos while maintaining whitelist curation
**Event Blacklist:**
- Block **ALL events** from specific authors (npubs)
- **Takes precedence over ALL other validation** (checked first)
- Applies to all event types: announcements, state events, PRs, comments, etc.
- Events never reach relay storage or purgatory
- Environment: `NGIT_EVENT_BLACKLIST=npub1spammer...,npub1abuser...`
- Use case: Block abusive users completely
### Precedence & Interaction
Validation order (from first to last):
1. **Event Blacklist** → Reject if author is blacklisted (ALL event types)
2. **Repository Blacklist** → Reject if repository/npub/identifier is blacklisted (announcements only)
3. **Repository Whitelist** → Accept if announcement lists service AND matches whitelist
4. **Archive Whitelist** → Accept if announcement matches whitelist (even without listing service)
5. **Default GRASP-01** → Accept if announcement lists service (no whitelist configured)
Examples:
```bash
# Curated relay blocking spam
NGIT_REPOSITORY_WHITELIST=npub1alice...,npub1bob...
NGIT_REPOSITORY_BLACKLIST=npub1alice.../spam-repo
NGIT_EVENT_BLACKLIST=npub1spammer...
# Result: Accept Alice & Bob's repos EXCEPT Alice's spam-repo, block all events from spammer
# Archive relay with moderation
NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux
NGIT_EVENT_BLACKLIST=npub1abuser...
# Result: Mirror bitcoin-core and linux projects, block all events from abuser
# Public relay with spam protection
NGIT_EVENT_BLACKLIST=npub1spam1...,npub1spam2...
# Result: Accept all GRASP-01 repos, block all events from spammers
```
**Privacy & Transparency:**
- Blacklists are **not advertised** in NIP-11 metadata (operational, not curation policy)
- Rejected events receive specific error messages for operator debugging
- No client-visible indication that blacklists are in use
**See**: [Configuration Reference](docs/reference/configuration.md) for complete details
## Roadmap
### GRASP-02 Enhancements
@@ -326,6 +413,17 @@ NGIT_RELAY_OWNER_NSEC=nsec1... ngit-grasp --domain relay.example.com
| Disable negentropy | `--sync-disable-negentropy` | `NGIT_SYNC_DISABLE_NEGENTROPY` | `false` |
| Batch window | N/A | `NGIT_SYNC_BATCH_WINDOW_MS` | `5000` ms |
#### Curation & Moderation Settings
| Option | CLI Flag | Environment Variable | Default |
| -------------------- | --------------------------- | ------------------------------ | --------- |
| Repository whitelist | `--repository-whitelist` | `NGIT_REPOSITORY_WHITELIST` | (empty) |
| Archive whitelist | `--archive-whitelist` | `NGIT_ARCHIVE_WHITELIST` | (empty) |
| Archive all | `--archive-all` | `NGIT_ARCHIVE_ALL` | `false` |
| Archive read-only | `--archive-read-only` | `NGIT_ARCHIVE_READ_ONLY` | (auto) |
| Repository blacklist | `--repository-blacklist` | `NGIT_REPOSITORY_BLACKLIST` | (empty) |
| Event blacklist | `--event-blacklist` | `NGIT_EVENT_BLACKLIST` | (empty) |
**Sync Notes:**
- **Bootstrap relay**: Optional starting point for relay discovery. System automatically discovers additional relays from repository announcements. URL scheme is optional - if not provided, `wss://` is assumed (e.g., `git.shakespeare.diy` → `wss://git.shakespeare.diy`).
+92
View File
@@ -833,6 +833,98 @@ Blacklist does **not** affect NIP-11 metadata:
---
### Event Blacklist
#### `NGIT_EVENT_BLACKLIST`
**Description:** Blacklist events from specific authors (npubs)
**Type:** Comma-separated list of npubs
**Default:** Empty (no events are blacklisted by author)
**Required:** No
**Format:**
- `npub1...` - Block all events from this author
**Precedence:** Event blacklist takes precedence over **ALL** other validation:
- Blacklisted events are rejected **before** any other policy checks
- Applies to all event types (announcements, state events, PRs, etc.)
- Events never reach purgatory (rejected immediately)
- Overrides repository blacklist, whitelists, and all other policies
**Examples:**
```bash
# Block all events from specific author
NGIT_EVENT_BLACKLIST=npub1spam...
# Block events from multiple authors
NGIT_EVENT_BLACKLIST=npub1spam...,npub1abuser...,npub1troll...
```
**Rejection Reason:**
The event blacklist provides a specific rejection reason:
- **Format:** `"Event author <npub> is blacklisted"`
This reason helps operators understand why an event was rejected without needing to flag it in metadata.
**Behavior:**
Event blacklist is checked **first** before all other validation:
1. Check event blacklist → Reject if author is blacklisted
2. Check repository blacklist (for announcements) → Reject if matched
3. Check event-type specific policies → Accept/Reject based on policy
4. Process event normally
**Use Cases:**
```bash
# Block spam/abusive users
NGIT_EVENT_BLACKLIST=npub1spammer...,npub1abuser...
# Block malicious actors
NGIT_EVENT_BLACKLIST=npub1malware...,npub1phisher...
# Temporary block for investigation
NGIT_EVENT_BLACKLIST=npub1suspicious...
```
**Comparison with Repository Blacklist:**
| Configuration | Scope | Checked When | Applies To |
|---------------|-------|--------------|------------|
| Event Blacklist | Author-based | **First** (before all policies) | **All events** from author |
| Repository Blacklist | Repo-based | Second (announcements only) | Specific repositories |
**Event Blacklist vs Repository Blacklist:**
```bash
# Scenario: npub1alice is event-blacklisted
NGIT_EVENT_BLACKLIST=npub1alice...
# Result:
# - ALL events from npub1alice are rejected (announcements, PRs, etc.)
# - Events never reach relay or purgatory
# - Rejection: "Event author npub1alice... is blacklisted"
# Scenario: npub1alice/repo is repository-blacklisted
NGIT_REPOSITORY_BLACKLIST=npub1alice.../malware
# Result:
# - Only announcements for npub1alice.../malware are rejected
# - Other events from npub1alice are still processed normally
# - PRs/state events for different repos from npub1alice are accepted
```
**NIP-11 Impact:**
Event blacklist does **not** affect NIP-11 metadata:
- No `curation` field changes (blacklist is operational, not policy)
- Blacklist is transparent to clients (rejected with specific reason)
- Operators can use blacklist without advertising moderation
---
### Logging Configuration
#### `RUST_LOG`
+14
View File
@@ -237,6 +237,19 @@ let
'';
};
eventBlacklist = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "npub1spam..." "npub1abuser..." ];
description = ''
Event blacklist for blocking all events from specific authors (npubs).
Takes precedence over ALL other validation (checked first).
ALL events from these authors are rejected from relay storage and purgatory.
Applies to announcements, state events, PRs, and all other event types.
Does not affect NIP-11 metadata (operational, not curation policy).
'';
};
user = mkOption {
type = types.str;
default = "ngit-grasp-${name}";
@@ -281,6 +294,7 @@ let
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist;
NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist;
RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName;
+110
View File
@@ -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<String>,
}
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<String> {
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<String> = 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());
}
}
+31 -1
View File
@@ -56,7 +56,13 @@ impl Nip34WritePolicy {
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
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<String> {
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<crate::purgatory::Purgatory> {
&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;
+4
View File
@@ -32,6 +32,8 @@ pub struct PolicyContext {
pub purgatory: Arc<Purgatory>,
/// Local relay for notifying WebSocket subscribers (set after relay creation)
pub local_relay: Arc<std::sync::RwLock<Option<LocalRelay>>>,
/// 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<std::path::PathBuf>,
purgatory: Arc<Purgatory>,
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,
}
}