feat(config): add repository blacklist to block specific repos/npubs/identifiers

Adds NGIT_REPOSITORY_BLACKLIST option for blocking repositories, taking precedence
over all whitelists (archive and repository) to enable moderation without affecting
curation policy.

Key features:
- Three blacklist formats: <npub>, <npub>/<identifier>, <identifier>
- Blacklist checked first before any other validation
- Overrides archive whitelist and repository whitelist
- Specific rejection reasons based on match type (npub/identifier/both)
- Not flagged in NIP-11 curation (operational, not policy)

Implementation:
- Add BlacklistConfig struct with check() method returning detailed reasons
- Add NGIT_REPOSITORY_BLACKLIST config option and blacklist_config() method
- Update validate_announcement() to check blacklist first with specific reasons
- 12 new unit tests covering all blacklist behavior and precedence

Configuration synced across all four sources:
- src/config.rs: Core implementation with BlacklistConfig
- .env.example: Comprehensive documentation with examples
- docs/reference/configuration.md: Complete reference documentation
- nix/module.nix: NixOS module option with environment mapping

Testing:
- 12 new tests for blacklist functionality (config + validation)
- All 332 library tests passing
- All 38 integration tests passing

Use cases:
- Block spam/malware repos by identifier
- Block abusive users by npub
- Block specific problematic repos by npub/identifier
- Temporary blocks for investigation
This commit is contained in:
DanConwayDev
2026-01-12 21:33:15 +00:00
parent 1948312d40
commit 70c577f10b
6 changed files with 462 additions and 2 deletions
+28 -1
View File
@@ -227,4 +227,31 @@
# NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
# Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive)
# Note: When set, NIP-11 curation field will indicate curated repository acceptance
# NGIT_REPOSITORY_WHITELIST=
# NGIT_REPOSITORY_WHITELIST=
# ============================================================================
# REPOSITORY BLACKLIST
# ============================================================================
# Blacklist specific repos/pubkeys/identifiers to reject
# Comma-separated list supporting three formats (same as whitelist formats):
# <npub> - Block all repos from this pubkey
# <npub>/<identifier> - Block specific repo
# <identifier> - Block repos with this identifier (any pubkey)
#
# Blacklist takes precedence over ALL whitelists:
# - Blacklisted repos are rejected even if they match archive or repository whitelists
# - Blacklisted repos are rejected even if they list our service
#
# Rejection reasons indicate the match type:
# - "Repository owner <npub> is blacklisted" (npub format)
# - "Repository <npub>/<identifier> is blacklisted" (npub/identifier format)
# - "Repository identifier <identifier> is blacklisted" (identifier format)
#
# CLI: --repository-blacklist <list>
# Default: (empty - no repositories are blacklisted)
# Examples:
# NGIT_REPOSITORY_BLACKLIST=npub1spam...
# NGIT_REPOSITORY_BLACKLIST=npub1alice.../bad-repo
# NGIT_REPOSITORY_BLACKLIST=malware-repo,spam-repo
# NGIT_REPOSITORY_BLACKLIST=
+1 -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**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode)
- **Repository Whitelist/Blacklist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) and blacklist (overrides all whitelists)
- **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
+89
View File
@@ -744,6 +744,95 @@ NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice...
---
### Repository Blacklist
#### `NGIT_REPOSITORY_BLACKLIST`
**Description:** Blacklist specific repositories/pubkeys/identifiers to reject
**Type:** Comma-separated list
**Default:** Empty (no repositories are blacklisted)
**Required:** No
**Format:** Same as whitelist formats:
- `npub1...` - Block all repos from this pubkey
- `npub1.../identifier` - Block specific repo
- `identifier` - Block repos with this identifier (any pubkey)
**Precedence:** Blacklist takes precedence over **ALL** whitelists:
- Blacklisted repos are rejected even if they match archive or repository whitelists
- Blacklisted repos are rejected even if they list our service
- Blacklist is checked **first** before any other validation
**Examples:**
```bash
# Block all repos from specific pubkey
NGIT_REPOSITORY_BLACKLIST=npub1spam...
# Block specific repo
NGIT_REPOSITORY_BLACKLIST=npub1alice.../malware-repo
# Block repos with specific identifiers
NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing
# Combined blacklist
NGIT_REPOSITORY_BLACKLIST=npub1spam...,npub1alice.../bad-repo,malware
```
**Rejection Reasons:**
The blacklist provides specific rejection reasons based on the match type:
- **Npub format:** `"Repository owner <npub> is blacklisted"`
- **Npub/identifier format:** `"Repository <npub>/<identifier> is blacklisted"`
- **Identifier format:** `"Repository identifier <identifier> is blacklisted"`
These reasons help operators understand why a repository was rejected without needing to flag it in curation metadata.
**Behavior:**
Blacklist is checked **before** all other validation:
1. Check blacklist → Reject if matched
2. Check if lists service → Accept if matches repository whitelist (if enabled)
3. Check archive config → Accept if matches archive whitelist (if enabled)
4. Reject otherwise
**Use Cases:**
```bash
# Block spam/malware repos
NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing
# Block abusive users
NGIT_REPOSITORY_BLACKLIST=npub1spammer...,npub1abuser...
# Block specific problematic repos
NGIT_REPOSITORY_BLACKLIST=npub1alice.../copyright-violation,npub1bob.../illegal-content
# Temporary block for investigation
NGIT_REPOSITORY_BLACKLIST=npub1suspicious.../repo-under-review
```
**Comparison with Whitelists:**
| Configuration | Blacklisted? | Matches Whitelist? | Lists Service? | Result |
|---------------|--------------|-------------------|----------------|---------|
| Blacklist only | Yes | N/A | N/A | ❌ Reject (blacklisted) |
| Blacklist only | No | N/A | Yes | ✅ Accept (GRASP-01) |
| Blacklist + Repository whitelist | Yes | Yes | Yes | ❌ Reject (blacklist wins) |
| Blacklist + Archive whitelist | Yes | Yes | No | ❌ Reject (blacklist wins) |
| Blacklist + Both whitelists | Yes | Yes | Yes | ❌ Reject (blacklist wins) |
| Blacklist only | No | N/A | No | ❌ Reject (no whitelist match) |
**NIP-11 Impact:**
Blacklist does **not** affect NIP-11 metadata:
- No `curation` field changes (blacklist is operational, not curation policy)
- Blacklist is transparent to clients (rejected with specific reason)
- Operators can use blacklist without advertising curation
---
### Logging Configuration
#### `RUST_LOG`
+14
View File
@@ -224,6 +224,19 @@ let
'';
};
repositoryBlacklist = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "npub1spam..." "npub1alice.../bad-repo" "malware" ];
description = ''
Repository blacklist for blocking specific repositories/pubkeys/identifiers.
Blacklist takes precedence over ALL whitelists (archive and repository).
Formats: <npub>, <npub>/<identifier>, <identifier>
Blacklisted repos are rejected with specific reasons (npub/identifier/both).
Does not affect NIP-11 curation field (operational, not curation policy).
'';
};
user = mkOption {
type = types.str;
default = "ngit-grasp-${name}";
@@ -267,6 +280,7 @@ let
NGIT_ARCHIVE_ALL = toString cfg.archiveAll;
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist;
RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName;
+143
View File
@@ -195,6 +195,55 @@ impl Default for RepositoryConfig {
}
}
/// Repository blacklist configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlacklistConfig {
/// Blacklist entries for blocking specific repositories
///
/// If empty, no repositories are blacklisted.
/// Blacklist takes precedence over both archive and repository whitelists.
pub blacklist: Vec<WhitelistEntry>,
}
impl BlacklistConfig {
/// Check if repository blacklist is enabled (non-empty blacklist)
pub fn enabled(&self) -> bool {
!self.blacklist.is_empty()
}
/// Check if an announcement matches the repository blacklist
///
/// Returns Some(reason) if blacklisted, None if not blacklisted.
/// The reason indicates what type of match occurred (npub, npub/identifier, or identifier).
pub fn check(&self, npub: &str, identifier: &str) -> Option<String> {
for entry in &self.blacklist {
if entry.matches(npub, identifier) {
let reason = match entry {
WhitelistEntry::Pubkey(_) => {
format!("Repository owner {} is blacklisted", npub)
}
WhitelistEntry::Repository { .. } => {
format!("Repository {}/{} is blacklisted", npub, identifier)
}
WhitelistEntry::Identifier(_) => {
format!("Repository identifier {} is blacklisted", identifier)
}
};
return Some(reason);
}
}
None
}
}
impl Default for BlacklistConfig {
fn default() -> Self {
Self {
blacklist: Vec::new(),
}
}
}
/// Database backend type for the relay
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
#[serde(rename_all = "lowercase")]
@@ -373,6 +422,12 @@ pub struct Config {
/// When set, only announcements matching the whitelist AND listing the service are accepted
#[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")]
pub repository_whitelist: String,
/// Repository blacklist: comma-separated list of npub/identifier/npub/identifier entries to reject
/// Formats: "npub1...", "npub1.../identifier", "identifier"
/// Blacklist takes precedence over all whitelists (archive and repository)
#[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")]
pub repository_blacklist: String,
}
impl Config {
@@ -549,6 +604,14 @@ impl Config {
RepositoryConfig { whitelist }
}
/// Get parsed repository blacklist configuration
///
/// This method assumes config has been validated - call Config::validate() first!
pub fn blacklist_config(&self) -> BlacklistConfig {
let blacklist = WhitelistEntry::parse_whitelist(&self.repository_blacklist);
BlacklistConfig { blacklist }
}
/// Create config for testing
#[cfg(test)]
pub fn for_testing() -> Self {
@@ -583,6 +646,7 @@ impl Config {
archive_whitelist: String::new(),
archive_read_only: None,
repository_whitelist: String::new(),
repository_blacklist: String::new(),
}
}
}
@@ -1105,4 +1169,83 @@ mod tests {
.to_string()
.contains("relay_owner_nsec not set"));
}
#[test]
fn test_blacklist_config_parsing() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
repository_blacklist: format!("{},bitcoin-core", test_npub),
..Config::for_testing()
};
let blacklist_config = config.blacklist_config();
assert_eq!(blacklist_config.blacklist.len(), 2);
assert!(blacklist_config.enabled());
}
#[test]
fn test_blacklist_config_empty() {
let config = Config::for_testing();
let blacklist_config = config.blacklist_config();
assert!(blacklist_config.blacklist.is_empty());
assert!(!blacklist_config.enabled());
}
#[test]
fn test_blacklist_check_npub() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = BlacklistConfig {
blacklist: vec![WhitelistEntry::Pubkey(test_npub.clone())],
};
let result = config.check(&test_npub, "any-repo");
assert!(result.is_some());
let reason = result.unwrap();
assert!(reason.contains("owner"));
assert!(reason.contains(&test_npub));
}
#[test]
fn test_blacklist_check_identifier() {
let config = BlacklistConfig {
blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())],
};
let result = config.check("npub1alice", "banned-repo");
assert!(result.is_some());
let reason = result.unwrap();
assert!(reason.contains("identifier"));
assert!(reason.contains("banned-repo"));
}
#[test]
fn test_blacklist_check_repository() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = BlacklistConfig {
blacklist: vec![WhitelistEntry::Repository {
npub: test_npub.clone(),
identifier: "specific-repo".to_string(),
}],
};
let result = config.check(&test_npub, "specific-repo");
assert!(result.is_some());
let reason = result.unwrap();
assert!(reason.contains(&test_npub));
assert!(reason.contains("specific-repo"));
}
#[test]
fn test_blacklist_check_not_blacklisted() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = BlacklistConfig {
blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())],
};
let result = config.check(&test_npub, "allowed-repo");
assert!(result.is_none());
}
}
+187
View File
@@ -366,6 +366,9 @@ impl RepositoryState {
/// - AcceptArchive: Announcement matches archive config (GRASP-05)
/// - Reject: Validation failed
///
/// Blacklist takes precedence over all whitelists:
/// - If blacklisted, always reject with specific reason (npub/identifier/npub+identifier)
///
/// When archive_read_only is true:
/// - ONLY accept announcements matching archive whitelist/all
/// - REJECT announcements listing our service but not in whitelist (read-only sync mode)
@@ -403,10 +406,16 @@ pub fn validate_announcement(
// Get validated configs (config.validate() must be called at startup)
let archive_config = config.archive_config();
let repository_config = config.repository_config();
let blacklist_config = config.blacklist_config();
let npub = announcement.owner_npub();
let lists_service = announcement.lists_service(&config.domain);
// Check blacklist FIRST - it overrides everything
if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) {
return AnnouncementResult::Reject(reason);
}
// GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled)
if lists_service && !archive_config.read_only {
// Check repository whitelist if enabled
@@ -1309,4 +1318,182 @@ mod tests {
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_blacklist_rejects_npub() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let npub = keys.public_key().to_bech32().unwrap();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with blacklist for this npub
let config = Config {
domain: "gitnostr.com".to_string(),
repository_blacklist: npub.clone(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("owner"));
assert!(reason.contains(&npub));
} else {
panic!("Expected Reject, got {:?}", result);
}
}
#[test]
fn test_blacklist_rejects_identifier() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"banned-repo",
vec!["https://gitnostr.com/alice/banned-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with blacklist for this identifier
let config = Config {
domain: "gitnostr.com".to_string(),
repository_blacklist: "banned-repo".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("identifier"));
assert!(reason.contains("banned-repo"));
} else {
panic!("Expected Reject, got {:?}", result);
}
}
#[test]
fn test_blacklist_rejects_specific_repository() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let npub = keys.public_key().to_bech32().unwrap();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"specific-repo",
vec!["https://gitnostr.com/alice/specific-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with blacklist for this specific repo
let config = Config {
domain: "gitnostr.com".to_string(),
repository_blacklist: format!("{}/specific-repo", npub),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains(&npub));
assert!(reason.contains("specific-repo"));
} else {
panic!("Expected Reject, got {:?}", result);
}
}
#[test]
fn test_blacklist_overrides_repository_whitelist() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let npub = keys.public_key().to_bech32().unwrap();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with both whitelist and blacklist - blacklist should win
let config = Config {
domain: "gitnostr.com".to_string(),
repository_whitelist: npub.clone(),
repository_blacklist: npub.clone(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_blacklist_overrides_archive_whitelist() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let npub = keys.public_key().to_bech32().unwrap();
// Create announcement that does NOT list our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://other-service.com/alice/test-repo.git"],
vec!["wss://other-service.com"],
);
// Config with archive whitelist and blacklist - blacklist should win
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: npub.clone(),
archive_read_only: Some(false),
repository_blacklist: npub.clone(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_blacklist_allows_non_blacklisted() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"allowed-repo",
vec!["https://gitnostr.com/alice/allowed-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with blacklist for different identifier
let config = Config {
domain: "gitnostr.com".to_string(),
repository_blacklist: "banned-repo".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
}