diff --git a/internal/monitoring/monitor_memory_test.go b/internal/monitoring/monitor_memory_test.go index d0cdeed0a..d3a850aa3 100644 --- a/internal/monitoring/monitor_memory_test.go +++ b/internal/monitoring/monitor_memory_test.go @@ -193,6 +193,7 @@ func TestPollPVEInstanceUsesRRDMemUsedFallback(t *testing.T) { lastAuthAttempt: make(map[string]time.Time), } defer mon.alertManager.Stop() + defer mon.notificationMgr.Stop() mon.pollPVEInstance(context.Background(), "test", client) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 8a959ff5d..0f3e38928 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1999,9 +1999,17 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error { return fmt.Errorf("webhook URL missing hostname") } - // Block localhost and loopback addresses (SSRF protection) + // Block localhost and loopback addresses (SSRF protection) unless allowlisted if host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasPrefix(host, "127.") { - return fmt.Errorf("webhook URLs pointing to localhost are not allowed for security reasons") + // Check if localhost is in the allowlist + localhostIP := net.ParseIP("127.0.0.1") + if !n.isIPInAllowlist(localhostIP) { + return fmt.Errorf("webhook URLs pointing to localhost are not allowed for security reasons") + } + log.Debug(). + Str("host", host). + Str("url", webhookURL). + Msg("Localhost webhook URL allowed via allowlist") } // Block link-local addresses diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 16cab0d1e..68484dfd7 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -199,7 +199,10 @@ func TestSendGroupedAppriseInvokesExecutor(t *testing.T) { } func TestSendGroupedAppriseHTTP(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + nm := NewNotificationManager("https://pulse.local") + defer nm.Stop() nm.SetGroupingWindow(0) nm.SetEmailConfig(EmailConfig{Enabled: false}) @@ -563,7 +566,10 @@ func TestSendTestNotificationApprise(t *testing.T) { } func TestSendTestNotificationAppriseHTTP(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + nm := NewNotificationManager("") + defer nm.Stop() nm.SetEmailConfig(EmailConfig{Enabled: false}) type apprisePayload struct { diff --git a/internal/notifications/queue.go b/internal/notifications/queue.go index 8e5b06c90..4c197be3a 100644 --- a/internal/notifications/queue.go +++ b/internal/notifications/queue.go @@ -38,11 +38,11 @@ type QueuedNotification struct { Attempts int `json:"attempts"` MaxAttempts int `json:"maxAttempts"` LastAttempt *time.Time `json:"lastAttempt,omitempty"` - LastError string `json:"lastError,omitempty"` + LastError *string `json:"lastError,omitempty"` CreatedAt time.Time `json:"createdAt"` NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` - PayloadBytes int `json:"payloadBytes,omitempty"` + PayloadBytes *int `json:"payloadBytes,omitempty"` } // NotificationQueue manages persistent notification delivery with retries and DLQ