22 KiB
Defensive Relay Features
ID: d6ee
Issue Summary
Implement defensive rate limiting and resource controls to protect the relay from abuse. Focus on per-connection and per-IP rate limits using rust-nostr relay-builder capabilities and custom extensions where needed.
UPDATE 2026-01-13: Need to review and validate the current approach before implementing. Concerns about over-engineering and maintaining code that relay-builder should handle. Per-IP throttling may be more appropriate for git data fetching (separate issue) than general relay operations.
✅ DECISION: PHASE 1 ONLY (Config-Based Protection)
Status: APPROVED - Implement Phase 1, defer Phase 2/3
Priority: High (addresses critical DoS vulnerability)
Review Complete - Key Decisions
✅ Review Tasks Completed (2026-01-14):
- Task 1: Validated current state - only database + WritePolicy configured
- Task 2: Validated docs - 96.7% accurate, one minor error found
- Task 3: Gap analysis - total connection limit is critical missing piece
- Task 4: Config-only path recommended (70% protection, 15% effort)
- Task 5: Upstream contribution not recommended (no demand, specialized use case)
Decision Rationale:
- Config-only is sufficient - Total connection limit addresses primary DoS vector
- Defer per-IP enforcement - Implement only if abuse detected in production
- Git throttling separate - Git endpoint needs separate IP-based throttling (analyze separately)
- Upstream later - If Phase 2 needed, prefer PR to rust-nostr/relay-builder
Phase 1: Explicit Rate Limits & Connection Limit (IMPLEMENT NOW)
Scope:
- Make RateLimit explicit in code (currently using defaults)
- max_reqs: 500 (max subscriptions per connection)
- notes_per_minute: 60 (max events per minute per connection)
- Add max_connections configuration (default: 500)
- Update all 4 config locations (CRITICAL - see AGENTS.md)
- Fix documentation error (filter limit 5000→500)
- Document Phase 2 deferral decision in defensive-measures.md
Files to modify:
src/nostr/builder.rs- Add explicit RateLimit and max_connectionssrc/config.rs- Add max_connections field.env.example- Add NGIT_MAX_CONNECTIONSnix/module.nix- Add maxConnections optiondocs/reference/configuration.md- Document new optiondocs/explanation/defensive-measures.md- Fix filter limit error + document Phase 2 deferral
Effort: 2-3 hours
Acceptance Criteria:
- RateLimit explicitly configured in builder.rs with comments
- max_connections configurable via NGIT_MAX_CONNECTIONS (default: 500)
- All 4 config locations synced
- Documentation error fixed (filter limit 5000→500)
- Documentation updated with Phase 2 deferral decision
- Integration test: Connection rejected after reaching total limit
- All tests passing
- Changes committed
Phase 2 & 3: Per-IP Enforcement (DEFERRED - Future Work)
Status: Deferred until abuse detected in production
When to implement:
- IF ConnectionTracker shows IPs exceeding 10 connections
- IF event spam patterns emerge from single IPs
- Monitor for 2-4 weeks after Phase 1 deployment
Preferred Implementation Path:
-
First choice: Contribute to rust-nostr/relay-builder as PR
- Propose IP-based rate limiting as optional feature
- Let upstream maintain the code
- Benefits entire Nostr ecosystem
- See: docs/explanation/defensive-analysis-of-other-relays.md for research
-
Fallback: Implement in ngit-grasp if upstream not interested
- Use original Phase 2 & 3 plan (see below)
- Per-IP connection enforcement (4-6 hours)
- Per-IP event rate limiting (6-8 hours)
Implementation details preserved below for future reference.
Git Endpoint IP-Based Throttling (MOVED TO SEPARATE ISSUE)
Status: ✅ Spun out to issue ff38
Rationale:
- Git data fetching has different threat model than Nostr relay
- HTTP endpoints, not WebSocket connections
- Different attack vectors (bandwidth, CPU for pack generation)
- Should NOT interact with relay code
See: Issue ff38 (git-endpoint-ip-throttling) for full analysis and implementation plan
PREVIOUS IMPLEMENTATION PLAN (FOR REFERENCE ONLY)
Findings
rust-nostr relay-builder Capabilities
Built-in Features (✅ Available):
- Per-connection subscription limits (
RateLimit.max_reqs, default: 500) - Per-connection event rate limits (
RateLimit.notes_per_minute, default: 60) - Total connection limit (
max_connections(n)) - Subscription ID length limit (
max_subid_length, default: 250) - Filter result limits (
max_filter_limit, default: 5000)
Custom ngit-grasp Features (✅ Implemented):
- Per-IP connection monitoring (
ConnectionTrackerinsrc/metrics/connection.rs)- Status: Monitoring only, does NOT enforce limits
- Tracks connections per IP, flags abusers at threshold (default: 10)
- Privacy-preserving: IP addresses never exposed in Prometheus metrics
- Event blacklist (block all events from specific authors)
- Repository blacklist (block specific repositories/developers)
- WritePolicy plugin system (access to IP address for custom validation)
Not Supported:
- Per-IP subscription limits (tracked per connection, not per IP)
- Per-IP event rate limits (tracked per connection, not per IP)
Architecture Decision
Rate Limiting Strategy
Per-Connection Limits (relay-builder):
- Use built-in
RateLimitconfiguration - Default values: 500 concurrent subscriptions, 60 events/min
- Prevents single connection from overwhelming relay
Per-IP Limits (custom implementation):
- Connection enforcement: Extend
ConnectionTrackerto reject connections - Event rate limiting: Add
IpRateLimitertoNip34WritePolicy - Hardcoded sensible defaults (configurable in future if needed)
Privacy Model:
- IP addresses tracked internally, NEVER exposed in Prometheus metrics
- Only aggregate counts exposed (total rate limited, flagged IPs)
- Rate limit events logged with IP for operator debugging
Metrics Changes: OUT OF SCOPE
Decision: Do NOT modify metrics in this issue. Current metrics implementation needs architectural review.
Reasoning:
- Current metrics use "abuse detection" threshold model
- New rate limiting uses enforcement model
- These should be aligned but require broader metrics redesign
- Deferring to separate issue to avoid scope creep
Future work:
- Create separate issue for metrics architectural review
- Align metrics with policy enforcement triggers
- Add rate limiting specific metrics (events rejected, IPs in cooldown)
Implementation Plan
Phase 1: Configure Built-in Rate Limits ⚡ (Quick Win)
Estimated Time: 1-2 hours
Can run in parallel: No (sequential)
Scope:
- Configure
RateLimitin relay builder with defaults (500 subs, 60 events/min) - Add total connection limit (500 connections, configurable)
Files to modify:
src/nostr/builder.rs- Add RateLimit and max_connections to LocalRelayBuildersrc/config.rs- Addmax_connectionsconfig field.env.example- Add NGIT_MAX_CONNECTIONS examplenix/module.nix- Add maxConnections NixOS optiondocs/reference/configuration.md- Document new option
Acceptance Criteria:
- ✅ Relay configured with RateLimit (500 subs, 60 events/min)
- ✅ Total connection limit configurable via NGIT_MAX_CONNECTIONS (default: 500)
- ✅ Integration test: Connection rejected after reaching total limit
- ✅ All tests passing
- ✅ Changes committed with message: "Add per-connection rate limits and total connection limit"
- ✅ Documentation updated in defensive-measures.md
Configuration sync (CRITICAL):
- Update all FOUR locations:
src/config.rs,docs/reference/configuration.md,nix/module.nix,.env.example
Phase 2: Per-IP Connection Enforcement
Estimated Time: 4-6 hours
Can run in parallel: No (depends on Phase 1)
Scope:
- Extend
ConnectionTrackerto enforce per-IP connection limit (hardcoded: 10) - Add actix-web middleware to check limit before WebSocket upgrade
- Return 429 Too Many Requests with retry-after header
Files to create/modify:
src/metrics/connection.rs- Modifyon_connect()to return Result, check limitsrc/middleware/ip_limits.rs- NEW: Actix middleware for connection enforcementsrc/middleware/mod.rs- NEW: Module exportssrc/lib.rs- Export middleware modulesrc/main.rs- Add middleware to actix-web app
Implementation Details:
ConnectionTracker changes:
pub struct ConnectionTracker {
max_per_ip: u32, // NEW: hardcoded to 10
// ... existing fields
}
pub fn on_connect(&self, ip: IpAddr) -> Result<(), String> {
// Check count BEFORE incrementing
let current = self.connections.get(&ip).map(|i| i.count).unwrap_or(0);
if current >= self.max_per_ip {
return Err(format!("Connection limit ({}) exceeded", self.max_per_ip));
}
// Existing increment logic...
Ok(())
}
Actix middleware approach:
// Middleware wraps relay handler
// Checks ConnectionTracker.on_connect() before WebSocket upgrade
// Returns 429 if limit exceeded
// Allows connection if under limit
Acceptance Criteria:
- ✅ ConnectionTracker enforces 10 connections per IP (hardcoded)
- ✅ 11th connection from same IP returns 429 Too Many Requests
- ✅ Error logged with IP address for operator debugging
- ✅ Integration test: Multiple connections from same IP rejected after limit
- ✅ Integration test: Connections from different IPs allowed
- ✅ All tests passing (including Phase 1 tests)
- ✅ Changes committed with message: "Enforce per-IP connection limit (max 10)"
- ✅ Documentation updated in defensive-measures.md
Phase 3: Per-IP Event Rate Limiting
Estimated Time: 6-8 hours
Can run in parallel: No (depends on Phase 1 & 2)
Scope:
- Implement token bucket rate limiter
- Integrate with
Nip34WritePolicyto check IP rate before event validation - Hardcoded limit: 100 events per minute per IP
Files to create/modify:
src/nostr/ip_rate_limiter.rs- NEW: Token bucket implementationsrc/nostr/builder.rs- Add IpRateLimiter to Nip34WritePolicysrc/nostr/mod.rs- Export ip_rate_limiter module
Implementation Details:
Token Bucket Algorithm:
pub struct IpRateLimiter {
limiters: DashMap<IpAddr, TokenBucket>,
events_per_minute: u32, // Hardcoded: 100
}
struct TokenBucket {
tokens: f64, // Current tokens (0.0 to events_per_minute)
last_refill: Instant, // Last refill time
capacity: f64, // Max tokens (events_per_minute)
refill_rate: f64, // Tokens per second (events_per_minute / 60.0)
}
impl IpRateLimiter {
pub fn check_and_consume(&self, ip: IpAddr) -> bool {
// Refill tokens based on time elapsed
// If tokens >= 1.0, consume 1 token and return true
// Else return false (rate limited)
}
}
Nip34WritePolicy integration:
impl WritePolicy for Nip34WritePolicy {
fn admit_event(&self, event: &Event, addr: &SocketAddr) -> BoxedFuture<WritePolicyResult> {
// Check IP rate limit FIRST (before blacklist checks)
if !self.ip_rate_limiter.check_and_consume(addr.ip()) {
tracing::warn!(ip = %addr.ip(), "Event rejected: IP rate limit exceeded");
return WritePolicyResult::reject("rate limited: too many events from your IP");
}
// Existing validation (blacklist, GRASP-01, etc.)...
}
}
Cleanup task:
- Spawn background task to remove stale IP entries (no activity for 5 minutes)
- Prevents unbounded memory growth
- Runs every 60 seconds
Acceptance Criteria:
- ✅ IpRateLimiter implements token bucket (100 events/min per IP)
- ✅ Integrated into WritePolicy (checked before event validation)
- ✅ 101st event from same IP within 1 minute rejected
- ✅ Events from different IPs allowed
- ✅ Stale IP entries cleaned up (no memory leak)
- ✅ Unit tests: Token bucket refill logic
- ✅ Unit tests: Rate limit enforcement
- ✅ Integration test: Send >100 events/min from single IP, verify rejection
- ✅ Integration test: Events accepted after rate limit window passes
- ✅ All tests passing (including Phase 1 & 2 tests)
- ✅ Changes committed with message: "Add per-IP event rate limiting (100 events/min)"
- ✅ Documentation updated in defensive-measures.md
Phase 4: Documentation & Polish
Estimated Time: 2-3 hours
Can run in parallel: No (depends on all phases)
Scope:
- Update all documentation to reflect implemented features
- Add operational notes for relay operators
- Update design docs with architectural decisions
Files to modify:
docs/explanation/defensive-measures.md- Move features from "Not Implemented" to "Implemented"README.md- Update defensive measures sectionAGENTS.md- Add notes about new rate limiting code patterns (if needed)
Acceptance Criteria:
- ✅ defensive-measures.md accurately reflects all implemented features
- ✅ README.md defensive measures section updated
- ✅ Configuration docs show all new options
- ✅ All four config locations synced (src/config.rs, docs, nix/module.nix, .env.example)
- ✅ Changes committed with message: "docs: Update defensive measures documentation"
Testing Strategy
Unit Tests
ConnectionTracker::on_connect()enforcement logic (Phase 2)IpRateLimiter::check_and_consume()token bucket (Phase 3)- Token refill calculations (Phase 3)
- Config parsing for max_connections (Phase 1)
Integration Tests
- Connection rejection after total limit (Phase 1)
- Connection rejection after per-IP limit (Phase 2)
- Event rejection after per-IP rate limit (Phase 3)
- Rate limit window expiry (Phase 3)
- Multi-IP scenarios (all phases)
Manual Testing (after all phases)
- Use
grasp-auditto simulate attack scenarios - Test with multiple clients from same IP
- Test with rapid event publishing
- Verify metrics don't expose IP addresses
Configuration Summary
Phase 1: New Config Options
# Total connection limit (default: 500)
NGIT_MAX_CONNECTIONS=500
Phase 2 & 3: Hardcoded Values
- Per-IP connection limit: 10 connections (hardcoded in ConnectionTracker)
- Per-IP event rate limit: 100 events/min (hardcoded in IpRateLimiter)
Rationale for hardcoding:
- Proven defensive values based on common relay patterns
- Reduces configuration surface area
- Can be made configurable later if operators need flexibility
- Simpler initial implementation
Privacy & Logging
IP Address Handling:
- ✅ Tracked internally in DashMap for rate limiting
- ✅ Logged when limits exceeded (operator debugging)
- ❌ NEVER exposed in Prometheus metrics (only aggregate counts)
- ❌ NEVER logged in normal operation (only when rate limited)
Log Examples:
WARN: Connection rejected from 192.168.1.100: Connection limit (10) exceeded
WARN: Event rejected from 192.168.1.100: IP rate limit exceeded (100 events/min)
Out of Scope (Future Work)
Metrics Architectural Review (Separate Issue)
Current state:
- Metrics use "abuse detection threshold" model (monitoring only)
- New rate limiting uses "enforcement" model (reject at limit)
- These should be unified
Future work:
- Create new issue for metrics redesign
- Add enforcement-specific metrics:
ngit_connections_rejected_total{reason="total_limit|per_ip_limit"}ngit_events_rejected_total{reason="ip_rate_limit|blacklist|..."}ngit_ip_rate_limited_active(gauge of IPs currently in cooldown)
- Align with policy enforcement points
- Remove or repurpose "abuse threshold" concept
Per-IP Subscription Limits
Why out of scope:
- Requires complex middleware to intercept REQ messages
- relay-builder tracks subscriptions per connection, not per IP
- Lower priority than event rate limiting
HTTP Endpoint Protection
Why out of scope:
- Different attack surface (HTTP vs WebSocket)
- Needs separate rate limiting strategy
- Lower priority for git-focused relay
Query Filtering (QueryPolicy)
Why out of scope:
- Not a critical defensive measure for git relay
- Can be added later if abuse patterns emerge
Per-IP Git Data Throttling (Potential Separate Issue)
Why might need separate issue:
- Git data fetching (packs, objects) has different characteristics than Nostr events
- Already have domain-based throttling for outbound git fetches (
ThrottleManager) - Inbound git push/fetch might benefit from per-IP limits
- Should be scoped separately from general relay rate limiting
- TODO: Consider creating new issue for git-specific per-IP throttling
Risks & Mitigations
Risk 1: Legitimate users behind NAT/proxy
Scenario: Multiple clients behind single IP hit connection/rate limits
Mitigation:
- Per-IP connection limit set reasonably high (10)
- Per-IP event rate limit set reasonably high (100/min)
- Monitor logs for false positives
- Can add IP whitelist in future if needed
Risk 2: Middleware integration complexity
Scenario: Actix middleware doesn't work with relay-builder
Mitigation:
- Test middleware approach early in Phase 2
- Fallback: Log warnings but don't enforce (monitoring mode)
- Alternative: Contribute hooks to relay-builder upstream
Risk 3: Memory growth from IP tracking
Scenario: DashMap grows unbounded with unique IPs
Mitigation:
- Cleanup task removes stale entries (5+ min inactive)
- Proven pattern already used in ConnectionTracker
- Monitor memory in production
Success Criteria
How we'll know this is working:
-
Protection active:
- 11th connection from same IP rejected (Phase 2)
- 101st event/min from same IP rejected (Phase 3)
- Logs show IP addresses when limits hit
-
No false positives:
- Legitimate users can connect and publish normally
- Sync from other relays works without rate limiting issues
- No user complaints about false rejections
-
Resource usage stable:
- No memory leaks from IP tracking
- Cleanup task removes stale entries
- CPU usage reasonable with token bucket calculations
-
Privacy maintained:
- IP addresses NEVER in Prometheus metrics
- Only aggregate counts exposed
- Metrics requests don't expose tracked IPs
Progress Tracking
- Phase 1: Configure built-in rate limits (1-2h)
- Phase 2: Per-IP connection enforcement (4-6h)
- Phase 3: Per-IP event rate limiting (6-8h)
- Phase 4: Documentation & polish (2-3h)
Total estimated time: 13-19 hours
Progress
2026-01-14 [Session 14:30] - REVIEW COMPLETE, PHASE 1 APPROVED
- Status Change: Review complete, proceeding with Phase 1 only
- Completed: All 5 review tasks using subagents
- Decision: Implement Phase 1 (config-only) with explicit rate limits
- Deferred: Phase 2 & 3 (per-IP enforcement) until abuse detected in production
- Separate concern: Git endpoint IP-based throttling (analyze separately)
Review Findings:
- Task 1: Current config uses only database + WritePolicy, all else is defaults
- Task 2: Documentation 96.7% accurate (1 minor error: filter limit 5000→500)
- Task 3: Critical gap is total connection limit (DoS vulnerability)
- Task 4: Config-only approach gives 70% protection for 15% effort
- Task 5: Upstream contribution not recommended (specialized use case, no demand)
Phase 1 Scope (APPROVED):
- Add explicit RateLimit configuration (make defaults visible in code)
- Add max_connections config option (default: 500)
- Update all 4 config locations (src/config.rs, docs, nix/module.nix, .env.example)
- Fix documentation error (filter limit)
- Effort: 2-3 hours
Phase 2 & 3 Deferred (Future Work if Abuse Detected):
- Per-IP connection enforcement (10 per IP)
- Per-IP event rate limiting (100 events/min per IP)
- Preferred approach: Contribute to rust-nostr/relay-builder as PR
- Fallback: Implement in ngit-grasp if upstream not interested
- Document implementation plan for future reference
Git Endpoint Throttling (Separate Analysis Needed):
- Git data fetching has different threat model than Nostr relay
- Needs IP-based throttling with sensible limits
- Should NOT interact with relay code
- Next: Analyze requirements with architect subagent
2026-01-13 [Session 21:15] - IMPLEMENTATION PAUSED
- Status Change: Paused implementation for validation review
- Concern: May be over-engineering with custom per-IP rate limiting
- Concern: Should relay-builder handle IP-based throttling, not ngit-grasp?
- Concern: Per-IP throttling might be more relevant for git data fetching (separate issue)
- Added: Review tasks section to validate current state before proceeding
- Decision: Complete review tasks before any implementation
- Next: Work through review tasks to determine simplest path forward
Key Questions:
- Is per-connection limiting (already in relay-builder) sufficient?
- Should we contribute IP rate limiting to upstream relay-builder?
- Should git data fetching have separate per-IP throttling issue?
- What's the minimal viable protection (prefer config over code)?
2026-01-13 [Session 20:43] - RESEARCH COMPLETED
- Completed: Comprehensive research on defensive features in major Nostr relays (strfry, nostr-rs-relay, khatru)
- Completed: Analysis of ngit-grasp's current defensive capabilities
- Completed: Research on Rust rate limiting ecosystem (governor crate)
- Created:
docs/explanation/defensive-analysis-of-other-relays.mdwith detailed findings Decision: The existing implementation plan is well-aligned with industry best practicesNext: Proceed with Phase 1 implementation (configure built-in rate limits)SUPERSEDED - see above
Key Findings:
- Most relays have permissive defaults; khatru is most opinionated (2 events per 3min/IP)
- Governor crate is industry standard for Rust rate limiting (GCRA algorithm)
- ngit-grasp already has good infrastructure (ConnectionTracker, ThrottleManager pattern)
- Proposed defaults are well-balanced compared to other relays
- However: Need to validate if this complexity is necessary for our use case
References
- rust-nostr relay-builder:
nostr-relay-builderv0.44.0 (git rev 4767ad13) - Token bucket algorithm: https://en.wikipedia.org/wiki/Token_bucket
- Actix-web middleware: https://actix.rs/docs/middleware/
- Current implementation:
src/metrics/connection.rs(ConnectionTracker) - Design docs:
docs/explanation/defensive-measures.md - Research:
docs/explanation/defensive-analysis-of-other-relays.md(comprehensive analysis)