Files
Charon/backend/internal/api/handlers/security_notifications.go
2026-03-04 18:34:49 +00:00

173 lines
5.8 KiB
Go

package handlers
import (
"context"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// SecurityNotificationServiceInterface defines the interface for security notification service.
type SecurityNotificationServiceInterface interface {
GetSettings() (*models.NotificationConfig, error)
UpdateSettings(*models.NotificationConfig) error
SendViaProviders(ctx context.Context, event models.SecurityEvent) error
}
// SecurityNotificationHandler handles notification settings endpoints.
type SecurityNotificationHandler struct {
service SecurityNotificationServiceInterface
securityService *services.SecurityService
dataRoot string
notificationService *services.NotificationService
managementCIDRs []string
}
// NewSecurityNotificationHandler creates a new handler instance.
func NewSecurityNotificationHandler(service SecurityNotificationServiceInterface) *SecurityNotificationHandler {
return NewSecurityNotificationHandlerWithDeps(service, nil, "", nil, nil)
}
func NewSecurityNotificationHandlerWithDeps(
service SecurityNotificationServiceInterface,
securityService *services.SecurityService,
dataRoot string,
notificationService *services.NotificationService,
managementCIDRs []string,
) *SecurityNotificationHandler {
return &SecurityNotificationHandler{
service: service,
securityService: securityService,
dataRoot: dataRoot,
notificationService: notificationService,
managementCIDRs: managementCIDRs,
}
}
// GetSettings retrieves the current notification settings.
func (h *SecurityNotificationHandler) GetSettings(c *gin.Context) {
settings, err := h.service.GetSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve settings"})
return
}
c.JSON(http.StatusOK, settings)
}
func (h *SecurityNotificationHandler) DeprecatedGetSettings(c *gin.Context) {
c.Header("X-Charon-Deprecated", "true")
c.Header("X-Charon-Canonical-Endpoint", "/api/v1/notifications/settings/security")
h.GetSettings(c)
}
// UpdateSettings is deprecated and returns 410 Gone (R6).
// Security settings must now be managed via provider Notification Events.
func (h *SecurityNotificationHandler) UpdateSettings(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.JSON(http.StatusGone, gin.H{
"error": "legacy_security_settings_deprecated",
"message": "Use provider Notification Events.",
"code": "LEGACY_SECURITY_SETTINGS_DEPRECATED",
})
}
func (h *SecurityNotificationHandler) DeprecatedUpdateSettings(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.JSON(http.StatusGone, gin.H{
"error": "legacy_security_settings_deprecated",
"message": "Use provider Notification Events.",
"code": "LEGACY_SECURITY_SETTINGS_DEPRECATED",
})
}
// HandleSecurityEvent receives runtime security events from Caddy/Cerberus (Blocker 1: Production dispatch path).
// This endpoint is called by Caddy bouncer/middleware when security events occur (WAF blocks, CrowdSec decisions, etc.).
func (h *SecurityNotificationHandler) HandleSecurityEvent(c *gin.Context) {
// Blocker 2: Source validation - verify request originates from localhost or management CIDRs
clientIPStr := util.CanonicalizeIPForSecurity(c.ClientIP())
clientIP := net.ParseIP(clientIPStr)
if clientIP == nil {
logger.Log().WithField("ip", util.SanitizeForLog(clientIPStr)).Warn("Security event intake: invalid client IP")
c.JSON(http.StatusForbidden, gin.H{
"error": "invalid_source",
"message": "Request source could not be validated",
})
return
}
// Check if IP is localhost (IPv4 or IPv6)
isLocalhost := clientIP.IsLoopback()
// Check if IP is in management CIDRs
isInManagementNetwork := false
for _, cidrStr := range h.managementCIDRs {
_, ipnet, err := net.ParseCIDR(cidrStr)
if err != nil {
logger.Log().WithError(err).WithField("cidr", util.SanitizeForLog(cidrStr)).Warn("Security event intake: invalid CIDR")
continue
}
if ipnet.Contains(clientIP) {
isInManagementNetwork = true
break
}
}
// Reject if not from localhost or management network
if !isLocalhost && !isInManagementNetwork {
logger.Log().WithField("ip", util.SanitizeForLog(clientIP.String())).Warn("Security event intake: IP not authorized")
c.JSON(http.StatusForbidden, gin.H{
"error": "unauthorized_source",
"message": "Request must originate from localhost or management network",
})
return
}
var event models.SecurityEvent
if err := c.ShouldBindJSON(&event); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid security event payload"})
return
}
// Set timestamp if not provided
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
// Log the event for audit trail
logger.Log().WithFields(map[string]interface{}{
"event_type": util.SanitizeForLog(event.EventType),
"severity": util.SanitizeForLog(event.Severity),
"client_ip": util.SanitizeForLog(event.ClientIP),
"path": util.SanitizeForLog(event.Path),
}).Info("Security event received")
c.Set("security_event_type", event.EventType)
c.Set("security_event_severity", event.Severity)
// Dispatch through provider-security-event authoritative path
// This enforces Discord-only rollout guarantee and proper event filtering
if err := h.service.SendViaProviders(c.Request.Context(), event); err != nil {
logger.Log().WithError(err).WithField("event_type", util.SanitizeForLog(event.EventType)).Error("Failed to dispatch security event")
// Continue - dispatch failure shouldn't prevent intake acknowledgment
}
c.JSON(http.StatusAccepted, gin.H{
"message": "Security event recorded",
"event_type": event.EventType,
"timestamp": event.Timestamp.Format(time.RFC3339),
})
}