21 KiB
Comprehensive Code Audit Report
Date: October 25, 2025 Scope: Full Notifier Service Codebase Auditor: Automated Code Review Status: 49 issues identified (2 critical, 7 high, 30 medium, 10 low)
Executive Summary
The Notifier service has a solid foundation with clean architecture and good separation of concerns. However, there are several issues that require immediate attention before production deployment:
- 2 Critical Issues: Memory leaks and security vulnerabilities
- 7 High Issues: Concurrency problems, architectural violations
- 30 Medium Issues: Performance, testing, and maintainability concerns
- 10 Low Issues: Code quality and documentation improvements
This report prioritizes these issues and provides actionable remediation steps.
CRITICAL ISSUES (Fix Immediately)
🔴 CRITICAL-1: Unbounded Memory Growth in Notification Storage
Severity: CRITICAL | Impact: Production crash after hours/days
Location: internal/service/service.go:23-24, 343-348
Problem: All notifications are stored in memory forever with no cleanup mechanism. In a production system with thousands of notifications per day, this will cause:
- Memory exhaustion
- Increasingly slow list operations (O(n) growth)
- Service crashes after 1-7 days depending on load
Current Code:
notifications map[string]*domain.Notification // Never cleaned up
func (s *NotificationService) storeNotification(notification *domain.Notification) {
s.mu.Lock()
defer s.mu.Unlock()
s.notifications[notification.ID] = notification // Grows indefinitely
}
Impact:
- 1000 notifications/day = ~365MB/year (assuming 100KB per notification)
- List operations degrade from ms to seconds
- Out-of-memory crashes after a few days
Fix Options:
-
Implement TTL-based eviction (Recommended)
type NotificationStore struct { data map[string]*domain.Notification ttl time.Duration mu sync.RWMutex } func (ns *NotificationStore) Cleanup(ctx context.Context) { ticker := time.NewTicker(ns.ttl / 2) for range ticker.C { ns.mu.Lock() now := time.Now() for id, notif := range ns.data { if now.Sub(notif.CreatedAt) > ns.ttl { delete(ns.data, id) } } ns.mu.Unlock() } } -
Use LRU Cache (Alternative)
import "github.com/hashicorp/golang-lru" cache, _ := lru.New(10000) // Keep last 10k notifications -
Implement database persistence (Longer-term)
- Move to PostgreSQL/MongoDB
- Implement proper queries for list/search
Recommendation: Implement TTL-based eviction with configurable TTL (default: 7 days)
🔴 CRITICAL-2: TLS Verification Bypass in ntfy Notifier
Severity: CRITICAL | Impact: MITM attacks, credential theft
Location: internal/notifier/ntfy.go:89-94
Problem:
The InsecureSkipVerify option allows disabling TLS certificate validation, enabling man-in-the-middle attacks.
Current Code:
if config.InsecureSkipVerify {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
Risk:
- Credentials transmitted over insecure connections
- Notification content interception
- No validation of notifier server identity
Fix:
-
Remove InsecureSkipVerify option entirely (Recommended)
// Remove from NtfyConfig struct // Remove from transport creation -
If self-signed certs are needed, add custom CA support:
type NtfyConfig struct { // ... existing fields ... CACertPath string `mapstructure:"ca_cert_path"` // Path to CA cert } func (n *NtfyNotifier) createHTTPClient() (*http.Client, error) { if n.config.CACertPath == "" { return &http.Client{Timeout: 30 * time.Second}, nil } caCert, err := ioutil.ReadFile(n.config.CACertPath) if err != nil { return nil, err } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) return &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: caCertPool, }, }, }, nil } -
Document the requirement:
- Make TLS verification mandatory in production
- Provide clear error messages if certs are invalid
Recommendation: Remove InsecureSkipVerify; add CACertPath for self-signed certificates.
🔴 CRITICAL-3: CORS Wildcard Allows Any Origin
Severity: CRITICAL | Impact: Cross-site request forgery attacks
Location: api/rest/router.go:54
Problem: The CORS configuration allows requests from ANY origin, violating CORS security principles.
Current Code:
w.Header().Set("Access-Control-Allow-Origin", "*")
Risk:
- Malicious websites can make requests to the API on behalf of authenticated users
- If combined with session cookies, enables CSRF attacks
- Credentials in Authorization header are sent regardless
Fix:
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Whitelist allowed origins
allowedOrigins := map[string]bool{
"https://example.com": true,
"https://app.example.com": true,
"http://localhost:3000": true, // Dev only
}
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
Configuration:
# config.yaml
server:
cors:
allowed_origins:
- "https://example.com"
- "https://app.example.com"
allow_credentials: true
Recommendation: Implement whitelist-based CORS with configurable origins.
HIGH PRIORITY ISSUES (Next Sprint)
🟠 HIGH-1: Unbounded JSON Payload Size
Severity: HIGH | Impact: DoS vulnerability, OOM crashes
Location: api/rest/handlers.go:31, 72
Problem: JSON decoder accepts unlimited request body sizes, allowing memory exhaustion attacks.
Current Code:
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// No size limit check
}
Fix:
const MaxRequestSize = 10 * 1024 * 1024 // 10MB
func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestSize)
defer r.Body.Close()
var req SendNotificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err.Error() == "http: request body too large" {
respondError(w, http.StatusRequestEntityTooLarge, "request body too large", nil)
return
}
respondError(w, http.StatusBadRequest, "invalid request body", err)
return
}
// ...
}
🟠 HIGH-2: Lock Contention in Service Layer
Severity: HIGH | Impact: Poor performance under load, bottleneck
Location: internal/service/service.go:23-24, 158-177
Problem: Every notification operation locks the entire notification map, causing severe contention.
Current Code:
s.mu.Lock() // Locks everything
s.notifications[notification.ID] = notification
defer s.mu.Unlock()
Impact:
- With 100 concurrent clients, 99 wait for the 1 holding the lock
- Response times grow linearly with concurrency
- Single-threaded bottleneck
Fix - Use Sharded Locks:
type NotificationService struct {
// ... existing fields ...
notificationShards [16]struct {
mu sync.RWMutex
notifications map[string]*domain.Notification
}
}
func (s *NotificationService) getShardIdx(id string) int {
hash := fnv.New32a()
hash.Write([]byte(id))
return int(hash.Sum32() % 16)
}
func (s *NotificationService) storeNotification(notification *domain.Notification) {
idx := s.getShardIdx(notification.ID)
s.notificationShards[idx].mu.Lock()
defer s.notificationShards[idx].mu.Unlock()
s.notificationShards[idx].notifications[notification.ID] = notification
}
func (s *NotificationService) GetNotification(ctx context.Context, id string) (*domain.Notification, error) {
idx := s.getShardIdx(id)
s.notificationShards[idx].mu.RLock()
defer s.notificationShards[idx].mu.RUnlock()
notif, exists := s.notificationShards[idx].notifications[id]
if !exists {
return nil, fmt.Errorf("notification not found")
}
return notif, nil
}
Benefit: 16x reduction in lock contention
🟠 HIGH-3: Goroutine Leak Potential in Workers
Severity: HIGH | Impact: Resource exhaustion over time
Location: internal/service/service.go:48-96
Problem:
Worker goroutines can leak if Stop() is never called or contexts are not properly cancelled.
Current Code:
for {
select {
case <-s.stopChan:
return
case <-ctx.Done():
return
default:
// Worker loop
}
}
Issue: If context is cancelled but stopChan is not closed, cleanup may not work.
Fix:
func (s *NotificationService) Start(ctx context.Context) error {
for i := 0; i < s.workerCount; i++ {
go func(id int) {
defer func() {
s.logger.Infof("Worker %d shutting down", id)
if r := recover(); r != nil {
s.logger.Errorf("Worker %d panicked: %v", id, r)
}
}()
s.worker(id, ctx)
}(i)
}
return nil
}
func (s *NotificationService) worker(id int, ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-s.stopChan:
return
case msg, ok := <-s.queue.Dequeue():
if !ok {
return // Channel closed
}
s.processNotification(ctx, msg)
}
}
}
func (s *NotificationService) Stop() error {
close(s.stopChan) // Signal all workers
// Wait for workers with timeout
timeout := time.After(30 * time.Second)
for i := 0; i < s.workerCount; i++ {
select {
case <-s.workerDoneChan:
// Worker exited
case <-timeout:
s.logger.Warnf("Timeout waiting for %d workers to stop", s.workerCount-i)
return fmt.Errorf("workers did not stop within timeout")
}
}
return nil
}
🟠 HIGH-4: Service Layer Mixed Responsibilities
Severity: HIGH | Impact: Hard to test, hard to maintain, tight coupling
Location: internal/service/service.go
Problem:
NotificationService has too many concerns:
- Queue management
- Notification storage
- Account resolution
- Filtering logic
- Statistics calculation
Current Code:
type NotificationService struct {
// Queue operations
queue domain.Queue
// Storage
notifications map[string]*domain.Notification
// Account resolution
config *config.Config
// Statistics
stats *NotificationStats
// ... more fields
}
// Single method does: filtering, querying, stats
func (s *NotificationService) ListNotifications(ctx context.Context, filter *domain.NotificationFilter) ([]*domain.Notification, error) {
// 100+ lines mixing filtering, storage, and stats
}
Fix - Separate Concerns:
// notifier.go - Responsible for queuing and worker management
type NotificationQueue interface {
Enqueue(ctx context.Context, notification *domain.Notification) error
Send(ctx context.Context, notification *domain.Notification) error
}
// repository.go - Responsible for storage
type NotificationRepository interface {
Store(notification *domain.Notification) error
Get(id string) (*domain.Notification, error)
List(ctx context.Context, filter *NotificationFilter) ([]*domain.Notification, error)
Delete(id string) error
}
// filter.go - Responsible for filtering logic
type NotificationFilter interface {
Apply(notifications []*domain.Notification) []*domain.Notification
}
// stats.go - Responsible for statistics
type StatsCollector interface {
Record(notification *domain.Notification, result *domain.NotificationResult)
GetStats() *domain.Stats
}
// service.go - Orchestrates the components
type NotificationService struct {
repository NotificationRepository
queue NotificationQueue
stats StatsCollector
filter NotificationFilter
}
🟠 HIGH-5: Inefficient Filtering Algorithm
Severity: HIGH | Impact: O(n*m) complexity, slow list operations
Location: internal/service/service.go:357-434
Problem: Recipients matching uses nested loops (O(n*m)).
Current Code:
for _, fr := range filter.Recipients {
for _, nr := range notification.Recipients {
if fr == nr {
found = true
break
}
}
}
With 10 notifications, 50 recipients each = 500 comparisons per filter
Fix:
func matchesRecipientFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
if len(filter.Recipients) == 0 {
return true
}
// O(m) instead of O(n*m)
filterSet := make(map[string]bool, len(filter.Recipients))
for _, r := range filter.Recipients {
filterSet[r] = true
}
for _, nr := range notification.Recipients {
if filterSet[nr] {
return true
}
}
return false
}
🟠 HIGH-6: RWMutex Lock Held During Channel Operations
Severity: HIGH | Impact: Deadlock potential, goroutine stalls
Location: internal/queue/local.go:55-85, 121-139
Problem: Lock is held while writing to channel, which can block if buffer is full.
Current Code:
lq.mu.Lock()
defer lq.mu.Unlock()
select {
case lq.queue <- msg: // Could block indefinitely with lock held!
// ...
}
Fix:
func (lq *LocalQueue) Enqueue(msg *domain.QueueMessage) error {
// Check if closed first (don't hold lock)
lq.mu.RLock()
if lq.closed {
lq.mu.RUnlock()
return fmt.Errorf("queue is closed")
}
queue := lq.queue // Copy reference
lq.mu.RUnlock()
// Send without holding lock
select {
case queue <- msg:
return nil
case <-time.After(5 * time.Second):
return fmt.Errorf("queue enqueue timeout")
}
}
🟠 HIGH-7: Temporal Dependencies in Rate Limiter
Severity: HIGH | Impact: Flaky tests, race conditions in testing
Location: internal/auth/auth.go:140-142
Problem:
Rate limiter uses time.Now() directly, making it hard to test.
Current Code:
now := time.Now()
if now.After(limiter.resetTime) {
limiter.count = 0
limiter.resetTime = now.Add(limiter.window)
}
Fix - Use Clock Interface:
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (rc RealClock) Now() time.Time { return time.Now() }
type RateLimiter struct {
maxRequests int
window time.Duration
resetTime time.Time
count int
clock Clock // Injected
mu sync.Mutex
}
func (rl *RateLimiter) IsAllowed() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.clock.Now() // Use injected clock
if now.After(rl.resetTime) {
rl.count = 0
rl.resetTime = now.Add(rl.window)
}
if rl.count >= rl.maxRequests {
return false
}
rl.count++
return true
}
// In tests:
type MockClock struct {
currentTime time.Time
}
func (mc MockClock) Now() time.Time { return mc.currentTime }
MEDIUM PRIORITY ISSUES (This Quarter)
🟡 MEDIUM-1: File Handle Not Closed
Location: internal/logging/logger.go:50-54
Impact: Resource leak, file descriptor exhaustion
Fix: Return interface with Close() method or use sync.Once for cleanup
🟡 MEDIUM-2: No Custom Error Types
Location: Entire codebase
Impact: Can't use errors.Is() / errors.As(), hard to handle specific errors
Fix: Create internal/errors/errors.go:
var (
ErrNotFound = errors.New("notification not found")
ErrQueueClosed = errors.New("queue is closed")
ErrNotifierNotFound = errors.New("notifier not found")
ErrRateLimited = errors.New("rate limit exceeded")
)
🟡 MEDIUM-3: No Structured Logging
Location: internal/logging/logger.go
Impact: Hard to parse logs, no structured fields
Fix: Migrate to log/slog (Go 1.21+) or use zap
🟡 MEDIUM-4: Inefficient String Search
Location: internal/notifier/notifier.go:95-103
Impact: O(n) instead of O(1), though impact is minimal
Fix: Use strings.Index(s, ":")
🟡 MEDIUM-5: Duplicate Key Generation Logic
Location: internal/notifier/notifier.go:25-32 vs internal/auth/authz.go:66-72
Impact: Code duplication, maintenance burden
Fix: Extract to internal/common/keys.go
🟡 MEDIUM-6: No Custom Error Types
Location: All notifier implementations Impact: Can't distinguish between different error types Fix: Create domain-specific error types
🟡 MEDIUM-7: Unsafe Configuration Defaults
Location: internal/config/config.go
Impact: Negative queue sizes or worker counts could cause panics
Fix: Validate configuration at load time
🟡 MEDIUM-8: Logger Not Interface
Location: internal/logging/logger.go
Impact: Hard to mock in tests
Fix: Extract Logger interface
🟡 MEDIUM-9: No Input Validation for URLs
Location: internal/notifier/slack.go, ntfy.go, smtp.go
Impact: Invalid URLs could cause crashes
Fix: Validate with url.Parse() and domain checks
🟡 MEDIUM-10: No Rate Limiting on API
Location: api/rest/router.go
Impact: Vulnerable to abuse
Fix: Add per-IP rate limiting middleware
[... 20 more medium issues listed in original report ...]
LOW PRIORITY ISSUES (Documentation & Code Quality)
- Missing package documentation
- Inconsistent receiver names (s, svc, notifier)
- Hardcoded timeout values (should be configurable)
- Unused config fields
- No error type wrapper for context errors
- SMTP boundary generation could use larger random values
- Missing gRPC health check implementation
Remediation Plan
Phase 1: Critical (1-2 weeks)
- ✅ Implement notification TTL/cleanup
- ✅ Remove TLS verification bypass
- ✅ Fix CORS configuration
- ✅ Add request size limits
Phase 2: High (2-4 weeks)
- ✅ Implement sharded locks
- ✅ Fix lock ordering issues
- ✅ Separate service concerns
- ✅ Fix filtering algorithm
- ✅ Fix goroutine lifecycle
Phase 3: Medium (1-2 sprints)
- ✅ Add custom error types
- ✅ Migrate to structured logging
- ✅ Add input validation
- ✅ Extract interfaces for testability
- ✅ Add configuration validation
Phase 4: Low (Ongoing)
- ✅ Add package documentation
- ✅ Improve code comments
- ✅ Consistent naming
- ✅ Remove unused code
Testing Gaps
Critical Test Coverage Missing:
- Concurrent notification storage/retrieval
- Queue overflow scenarios
- Rate limit window boundaries
- Auth token expiration
- Large request body handling
- Graceful shutdown with in-flight requests
Recommendation: Add integration tests for critical paths.
Security Checklist
- Remove InsecureSkipVerify from ntfy config
- Add request size limits to all endpoints
- Restrict CORS origins
- Validate all URL inputs
- Remove PII from logs
- Add input validation for email addresses
- Implement rate limiting
- Review credential handling
- Add security headers (X-Frame-Options, etc.)
- Audit all external dependencies
Conclusion
The Notifier service has a solid foundation but needs focused work on:
- Production readiness (memory leaks, rate limiting)
- Scalability (lock contention, filtering efficiency)
- Security (TLS validation, CORS, input validation)
- Testability (interfaces, dependency injection)
- Maintainability (error types, structured logging, separation of concerns)
Estimated effort to address all issues: 4-6 weeks with a focused team.
Recommended approach: Address critical issues first, then high-priority issues, then tackle medium-priority items as part of regular development.