Files
Charon/backend/internal/services/notification_service.go

557 lines
19 KiB
Go

package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
neturl "net/url"
"regexp"
"strings"
"text/template"
"time"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
"github.com/Wikid82/charon/backend/internal/trace"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/util"
"github.com/containrrr/shoutrrr"
"gorm.io/gorm"
)
type NotificationService struct {
DB *gorm.DB
}
func NewNotificationService(db *gorm.DB) *NotificationService {
return &NotificationService{DB: db}
}
var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`)
var allowedDiscordWebhookHosts = map[string]struct{}{
"discord.com": {},
"canary.discord.com": {},
}
func normalizeURL(serviceType, rawURL string) string {
if serviceType == "discord" {
matches := discordWebhookRegex.FindStringSubmatch(rawURL)
if len(matches) == 3 {
id := matches[1]
token := matches[2]
return fmt.Sprintf("discord://%s@%s", token, id)
}
}
return rawURL
}
func validateDiscordWebhookURL(rawURL string) error {
parsedURL, err := neturl.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid Discord webhook URL: failed to parse URL; use the HTTPS webhook URL provided by Discord")
}
if strings.EqualFold(parsedURL.Scheme, "discord") {
return nil
}
if !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("invalid Discord webhook URL: URL must use HTTPS and the hostname URL provided by Discord")
}
hostname := strings.ToLower(parsedURL.Hostname())
if hostname == "" {
return fmt.Errorf("invalid Discord webhook URL: missing hostname; use the HTTPS webhook URL provided by Discord")
}
if net.ParseIP(hostname) != nil {
return fmt.Errorf("invalid Discord webhook URL: IP address hosts are not allowed; use the hostname URL provided by Discord (discord.com or canary.discord.com)")
}
if _, ok := allowedDiscordWebhookHosts[hostname]; !ok {
return fmt.Errorf("invalid Discord webhook URL: host must be discord.com or canary.discord.com; use the hostname URL provided by Discord")
}
return nil
}
func validateDiscordProviderURL(providerType, rawURL string) error {
if !strings.EqualFold(providerType, "discord") {
return nil
}
return validateDiscordWebhookURL(rawURL)
}
// supportsJSONTemplates returns true if the provider type can use JSON templates
func supportsJSONTemplates(providerType string) bool {
switch strings.ToLower(providerType) {
case "webhook", "discord", "slack", "gotify", "generic":
return true
case "telegram":
return false // Telegram uses URL parameters
default:
return false
}
}
// Internal Notifications (DB)
func (s *NotificationService) Create(nType models.NotificationType, title, message string) (*models.Notification, error) {
notification := &models.Notification{
Type: nType,
Title: title,
Message: message,
Read: false,
}
result := s.DB.Create(notification)
return notification, result.Error
}
func (s *NotificationService) List(unreadOnly bool) ([]models.Notification, error) {
var notifications []models.Notification
query := s.DB.Order("created_at desc")
if unreadOnly {
query = query.Where("read = ?", false)
}
result := query.Find(&notifications)
return notifications, result.Error
}
func (s *NotificationService) MarkAsRead(id string) error {
return s.DB.Model(&models.Notification{}).Where("id = ?", id).Update("read", true).Error
}
func (s *NotificationService) MarkAllAsRead() error {
return s.DB.Model(&models.Notification{}).Where("read = ?", false).Update("read", true).Error
}
// External Notifications (Shoutrrr & Custom Webhooks)
func (s *NotificationService) SendExternal(ctx context.Context, eventType, title, message string, data map[string]any) {
var providers []models.NotificationProvider
if err := s.DB.Where("enabled = ?", true).Find(&providers).Error; err != nil {
logger.Log().WithError(err).Error("Failed to fetch notification providers")
return
}
// Prepare data for templates
if data == nil {
data = make(map[string]any)
}
data["Title"] = title
data["Message"] = message
data["Time"] = time.Now().Format(time.RFC3339)
data["EventType"] = eventType
for _, provider := range providers {
// Filter based on preferences
shouldSend := false
switch eventType {
case "proxy_host":
shouldSend = provider.NotifyProxyHosts
case "remote_server":
shouldSend = provider.NotifyRemoteServers
case "domain":
shouldSend = provider.NotifyDomains
case "cert":
shouldSend = provider.NotifyCerts
case "uptime":
shouldSend = provider.NotifyUptime
case "test":
shouldSend = true
default:
// Default to true for unknown types or generic messages?
// Or false to be safe? Let's say true for now to avoid missing things,
// or maybe we should enforce types.
shouldSend = true
}
if !shouldSend {
continue
}
go func(p models.NotificationProvider) {
// Use JSON templates for all supported services
if supportsJSONTemplates(p.Type) && p.Template != "" {
if err := s.sendJSONPayload(ctx, p, data); err != nil {
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send JSON notification")
}
} else {
url := normalizeURL(p.Type, p.URL)
// Validate HTTP/HTTPS destinations used by shoutrrr to reduce SSRF risk
// Using security.ValidateExternalURL to break CodeQL taint chain for CWE-918
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
if _, err := security.ValidateExternalURL(url,
security.WithAllowHTTP(),
security.WithAllowLocalhost(),
); err != nil {
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Skipping notification for provider due to invalid destination")
return
}
}
// Use newline for better formatting in chat apps
msg := fmt.Sprintf("%s\n\n%s", title, message)
if err := shoutrrrSendFunc(url, msg); err != nil {
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send notification")
}
}
}(provider)
}
}
// shoutrrrSendFunc is a test hook for outbound sends.
// In production it defaults to shoutrrr.Send.
var shoutrrrSendFunc = shoutrrr.Send
// webhookDoRequestFunc is a test hook for outbound JSON webhook requests.
// In production it defaults to (*http.Client).Do.
var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}
func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.NotificationProvider, data map[string]any) error {
// Built-in templates
const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}`
const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}`
// Select template based on provider.Template; if 'custom' use Config; else builtin.
tmplStr := p.Config
switch strings.ToLower(strings.TrimSpace(p.Template)) {
case "detailed":
tmplStr = detailedTemplate
case "minimal":
tmplStr = minimalTemplate
case "custom":
if tmplStr == "" {
tmplStr = minimalTemplate
}
default:
if tmplStr == "" {
tmplStr = minimalTemplate
}
}
// Template size limit validation (10KB max)
const maxTemplateSize = 10 * 1024
if len(tmplStr) > maxTemplateSize {
return fmt.Errorf("template size exceeds maximum limit of %d bytes", maxTemplateSize)
}
// Validate webhook URL using the security package's SSRF-safe validator.
// ValidateExternalURL performs comprehensive validation including:
// - URL format and scheme validation (http/https only)
// - DNS resolution and IP blocking for private/reserved ranges
// - Protection against cloud metadata endpoints (169.254.169.254)
// Using the security package's function helps CodeQL recognize the sanitization.
//
// Additionally, we apply `isValidRedirectURL` as a barrier-guard style predicate.
// CodeQL recognizes this pattern as a sanitizer for untrusted URL values, while
// the real SSRF protection remains `security.ValidateExternalURL`.
if err := validateDiscordProviderURL(p.Type, p.URL); err != nil {
return err
}
webhookURL := p.URL
if !isValidRedirectURL(webhookURL) {
return fmt.Errorf("invalid webhook url")
}
validatedURLStr, err := security.ValidateExternalURL(webhookURL,
security.WithAllowHTTP(), // Allow both http and https for webhooks
security.WithAllowLocalhost(), // Allow localhost for testing
)
if err != nil {
return fmt.Errorf("invalid webhook url: %w", err)
}
// Parse template and add helper funcs
tmpl, err := template.New("webhook").Funcs(template.FuncMap{
"toJSON": func(v any) string {
b, _ := json.Marshal(v)
return string(b)
},
}).Parse(tmplStr)
if err != nil {
return fmt.Errorf("failed to parse webhook template: %w", err)
}
// Template execution with timeout (5 seconds)
var body bytes.Buffer
execDone := make(chan error, 1)
go func() {
execDone <- tmpl.Execute(&body, data)
}()
select {
case execErr := <-execDone:
if execErr != nil {
return fmt.Errorf("failed to execute webhook template: %w", execErr)
}
case <-time.After(5 * time.Second):
return fmt.Errorf("template execution timeout after 5 seconds")
}
// Service-specific JSON validation
var jsonPayload map[string]any
if unmarshalErr := json.Unmarshal(body.Bytes(), &jsonPayload); unmarshalErr != nil {
return fmt.Errorf("invalid JSON payload: %w", unmarshalErr)
}
// Validate service-specific requirements
switch strings.ToLower(p.Type) {
case "discord":
// Discord requires either 'content' or 'embeds'
if _, hasContent := jsonPayload["content"]; !hasContent {
if _, hasEmbeds := jsonPayload["embeds"]; !hasEmbeds {
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
jsonPayload["content"] = messageValue
normalizedBody, marshalErr := json.Marshal(jsonPayload)
if marshalErr != nil {
return fmt.Errorf("failed to normalize discord payload: %w", marshalErr)
}
body.Reset()
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
return fmt.Errorf("failed to write normalized discord payload: %w", writeErr)
}
} else {
return fmt.Errorf("discord payload requires 'content' or 'embeds' field")
}
}
}
case "slack":
// Slack requires either 'text' or 'blocks'
if _, hasText := jsonPayload["text"]; !hasText {
if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks {
return fmt.Errorf("slack payload requires 'text' or 'blocks' field")
}
}
case "gotify":
// Gotify requires 'message' field
if _, hasMessage := jsonPayload["message"]; !hasMessage {
return fmt.Errorf("gotify payload requires 'message' field")
}
}
// Send Request with a safe client (SSRF protection, timeout, no auto-redirect)
// Using network.NewSafeHTTPClient() for defense-in-depth against SSRF attacks.
client := network.NewSafeHTTPClient(
network.WithTimeout(10*time.Second),
network.WithAllowLocalhost(), // Allow localhost for testing
)
req, err := http.NewRequestWithContext(ctx, "POST", validatedURLStr, &body)
if err != nil {
return fmt.Errorf("failed to create webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Propagate request id header if present in context
if rid := ctx.Value(trace.RequestIDKey); rid != nil {
if ridStr, ok := rid.(string); ok {
req.Header.Set("X-Request-ID", ridStr)
}
}
// Safe: URL validated by security.ValidateExternalURL() which validates URL
// format/scheme and blocks private/reserved destinations through DNS+dial-time checks.
// Safe: URL validated by security.ValidateExternalURL() which:
// 1. Validates URL format and scheme (HTTPS required in production)
// 2. Resolves DNS and blocks private/reserved IPs (RFC 1918, loopback, link-local)
// 3. Uses ssrfSafeDialer for connection-time IP revalidation (TOCTOU protection)
// 4. No redirect following allowed
// See: internal/security/url_validator.go
resp, err := webhookDoRequestFunc(client, req)
if err != nil {
return fmt.Errorf("failed to send webhook: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close webhook response body")
}
}()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook returned status: %d", resp.StatusCode)
}
return nil
}
// isPrivateIP returns true for RFC1918, loopback and link-local addresses.
// This wraps network.IsPrivateIP for backward compatibility and local use.
func isPrivateIP(ip net.IP) bool {
return network.IsPrivateIP(ip)
}
func isValidRedirectURL(rawURL string) bool {
u, err := neturl.Parse(rawURL)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
if u.Hostname() == "" {
return false
}
return true
}
func (s *NotificationService) TestProvider(provider models.NotificationProvider) error {
if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil {
return err
}
if supportsJSONTemplates(provider.Type) && provider.Template != "" {
data := map[string]any{
"Title": "Test Notification",
"Message": "This is a test notification from Charon",
"Status": "TEST",
"Name": "Test Monitor",
"Latency": 123,
"Time": time.Now().Format(time.RFC3339),
}
return s.sendJSONPayload(context.Background(), provider, data)
}
url := normalizeURL(provider.Type, provider.URL)
// SSRF validation for HTTP/HTTPS URLs used by shoutrrr
// Using security.ValidateExternalURL to break CodeQL taint chain for CWE-918.
// Non-HTTP schemes (e.g., discord://, slack://) are protocol-specific and don't
// directly expose SSRF risks since shoutrrr handles their network connections.
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
if _, err := security.ValidateExternalURL(url,
security.WithAllowHTTP(),
security.WithAllowLocalhost(),
); err != nil {
return fmt.Errorf("invalid notification URL: %w", err)
}
}
return shoutrrrSendFunc(url, "Test notification from Charon")
}
// ListTemplates returns all external notification templates stored in the database.
func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) {
var list []models.NotificationTemplate
if err := s.DB.Order("created_at desc").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// GetTemplate returns a single notification template by its ID.
func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) {
var t models.NotificationTemplate
if err := s.DB.Where("id = ?", id).First(&t).Error; err != nil {
return nil, err
}
return &t, nil
}
// CreateTemplate stores a new notification template in the database.
func (s *NotificationService) CreateTemplate(t *models.NotificationTemplate) error {
return s.DB.Create(t).Error
}
// UpdateTemplate saves updates to an existing notification template.
func (s *NotificationService) UpdateTemplate(t *models.NotificationTemplate) error {
return s.DB.Save(t).Error
}
// DeleteTemplate removes a notification template by its ID.
func (s *NotificationService) DeleteTemplate(id string) error {
return s.DB.Delete(&models.NotificationTemplate{}, "id = ?", id).Error
}
// RenderTemplate renders a provider template with provided data and returns
// the rendered JSON string and the parsed object for previewing/validation.
func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]any) (resp string, parsed any, err error) {
// Built-in templates
const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}`
const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}`
tmplStr := p.Config
switch strings.ToLower(strings.TrimSpace(p.Template)) {
case "detailed":
tmplStr = detailedTemplate
case "minimal":
tmplStr = minimalTemplate
case "custom":
if tmplStr == "" {
tmplStr = minimalTemplate
}
default:
if tmplStr == "" {
tmplStr = minimalTemplate
}
}
// Parse and execute template with helper funcs
tmpl, err := template.New("webhook").Funcs(template.FuncMap{
"toJSON": func(v any) string {
b, _ := json.Marshal(v)
return string(b)
},
}).Parse(tmplStr)
if err != nil {
return "", nil, fmt.Errorf("failed to parse webhook template: %w", err)
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
return "", nil, fmt.Errorf("failed to execute webhook template: %w", err)
}
// Validate produced JSON
if err := json.Unmarshal(body.Bytes(), &parsed); err != nil {
return body.String(), nil, fmt.Errorf("failed to parse rendered template: %w", err)
}
return body.String(), parsed, nil
}
// Provider Management
func (s *NotificationService) ListProviders() ([]models.NotificationProvider, error) {
var providers []models.NotificationProvider
result := s.DB.Find(&providers)
return providers, result.Error
}
func (s *NotificationService) CreateProvider(provider *models.NotificationProvider) error {
if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil {
return err
}
// Validate custom template before creating
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
// Provide a minimal preview payload
payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
return fmt.Errorf("invalid custom template: %w", err)
}
}
return s.DB.Create(provider).Error
}
func (s *NotificationService) UpdateProvider(provider *models.NotificationProvider) error {
if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil {
return err
}
// Validate custom template before saving
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
return fmt.Errorf("invalid custom template: %w", err)
}
}
return s.DB.Save(provider).Error
}
func (s *NotificationService) DeleteProvider(id string) error {
return s.DB.Delete(&models.NotificationProvider{}, "id = ?", id).Error
}