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(¬ifications) 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 }