- Refactored `SyncMonitors` method in `uptime_service.go` for better readability. - Updated unit tests for `UptimeService` to ensure proper functionality. - Introduced Playwright configuration for end-to-end testing. - Added e2e tests for WAF blocking and monitoring functionality. - Enhanced the Security page to include WAF mode and rule set selection. - Implemented tests for WAF configuration changes and validation. - Created a `.last-run.json` file to store test results.
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/caddy"
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
)
|
|
|
|
// SecurityHandler handles security-related API requests.
|
|
type SecurityHandler struct {
|
|
cfg config.SecurityConfig
|
|
db *gorm.DB
|
|
svc *services.SecurityService
|
|
caddyManager *caddy.Manager
|
|
}
|
|
|
|
// NewSecurityHandler creates a new SecurityHandler.
|
|
func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager) *SecurityHandler {
|
|
svc := services.NewSecurityService(db)
|
|
return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager}
|
|
}
|
|
|
|
// GetStatus returns the current status of all security services.
|
|
func (h *SecurityHandler) GetStatus(c *gin.Context) {
|
|
enabled := h.cfg.CerberusEnabled
|
|
// Check runtime setting override
|
|
var settingKey = "security.cerberus.enabled"
|
|
if h.db != nil {
|
|
var setting struct{ Value string }
|
|
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&setting).Error; err == nil && setting.Value != "" {
|
|
if strings.EqualFold(setting.Value, "true") {
|
|
enabled = true
|
|
} else {
|
|
enabled = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Allow runtime overrides for CrowdSec mode + API URL via settings table
|
|
mode := h.cfg.CrowdSecMode
|
|
apiURL := h.cfg.CrowdSecAPIURL
|
|
if h.db != nil {
|
|
var m struct{ Value string }
|
|
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&m).Error; err == nil && m.Value != "" {
|
|
mode = m.Value
|
|
}
|
|
var a struct{ Value string }
|
|
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.api_url").Scan(&a).Error; err == nil && a.Value != "" {
|
|
apiURL = a.Value
|
|
}
|
|
}
|
|
|
|
// Only allow 'local' as an enabled mode. Any other value should be treated as disabled.
|
|
if mode != "local" {
|
|
mode = "disabled"
|
|
apiURL = ""
|
|
}
|
|
|
|
// Allow runtime override for ACL enabled flag via settings table
|
|
aclEnabled := h.cfg.ACLMode == "enabled"
|
|
aclEffective := aclEnabled && enabled
|
|
if h.db != nil {
|
|
var a struct{ Value string }
|
|
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.acl.enabled").Scan(&a).Error; err == nil && a.Value != "" {
|
|
if strings.EqualFold(a.Value, "true") {
|
|
aclEnabled = true
|
|
} else if strings.EqualFold(a.Value, "false") {
|
|
aclEnabled = false
|
|
}
|
|
|
|
// If Cerberus is disabled, ACL should not be considered enabled even
|
|
// if the ACL setting is true. This keeps ACL tied to the Cerberus
|
|
// suite state in the UI and APIs.
|
|
aclEffective = aclEnabled && enabled
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"cerberus": gin.H{"enabled": enabled},
|
|
"crowdsec": gin.H{
|
|
"mode": mode,
|
|
"api_url": apiURL,
|
|
"enabled": mode == "local",
|
|
},
|
|
"waf": gin.H{
|
|
"mode": h.cfg.WAFMode,
|
|
"enabled": h.cfg.WAFMode != "" && h.cfg.WAFMode != "disabled",
|
|
},
|
|
"rate_limit": gin.H{
|
|
"mode": h.cfg.RateLimitMode,
|
|
"enabled": h.cfg.RateLimitMode == "enabled",
|
|
},
|
|
"acl": gin.H{
|
|
"mode": h.cfg.ACLMode,
|
|
"enabled": aclEffective,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetConfig returns the site security configuration from DB or default
|
|
func (h *SecurityHandler) GetConfig(c *gin.Context) {
|
|
cfg, err := h.svc.Get()
|
|
if err != nil {
|
|
if err == services.ErrSecurityConfigNotFound {
|
|
c.JSON(http.StatusOK, gin.H{"config": nil})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"config": cfg})
|
|
}
|
|
|
|
// UpdateConfig creates or updates the SecurityConfig in DB
|
|
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
|
var payload models.SecurityConfig
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if payload.Name == "" {
|
|
payload.Name = "default"
|
|
}
|
|
if err := h.svc.Upsert(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"config": payload})
|
|
}
|
|
|
|
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
|
|
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
|
|
token, err := h.svc.GenerateBreakGlassToken("default")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
|
}
|
|
|
|
// ListDecisions returns recent security decisions
|
|
func (h *SecurityHandler) ListDecisions(c *gin.Context) {
|
|
limit := 50
|
|
if q := c.Query("limit"); q != "" {
|
|
if v, err := strconv.Atoi(q); err == nil {
|
|
limit = v
|
|
}
|
|
}
|
|
list, err := h.svc.ListDecisions(limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"decisions": list})
|
|
}
|
|
|
|
// CreateDecision creates a manual decision (override) - for now no checks besides payload
|
|
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
|
|
var payload models.SecurityDecision
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if payload.IP == "" || payload.Action == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
|
|
return
|
|
}
|
|
// Populate source
|
|
payload.Source = "manual"
|
|
if err := h.svc.LogDecision(&payload); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
|
|
return
|
|
}
|
|
// Record an audit entry
|
|
actor := c.GetString("user_id")
|
|
if actor == "" {
|
|
actor = c.ClientIP()
|
|
}
|
|
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
|
|
c.JSON(http.StatusOK, gin.H{"decision": payload})
|
|
}
|
|
|
|
// ListRuleSets returns the list of known rulesets
|
|
func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
|
|
list, err := h.svc.ListRuleSets()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"rulesets": list})
|
|
}
|
|
|
|
// UpsertRuleSet uploads or updates a ruleset
|
|
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
|
var payload models.SecurityRuleSet
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if payload.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
|
return
|
|
}
|
|
if err := h.svc.UpsertRuleSet(&payload); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
|
|
return
|
|
}
|
|
if h.caddyManager != nil {
|
|
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
// Create an audit event
|
|
actor := c.GetString("user_id")
|
|
if actor == "" {
|
|
actor = c.ClientIP()
|
|
}
|
|
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
|
|
c.JSON(http.StatusOK, gin.H{"ruleset": payload})
|
|
}
|
|
|
|
// DeleteRuleSet removes a ruleset by id
|
|
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
|
|
idParam := c.Param("id")
|
|
if idParam == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
|
return
|
|
}
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := h.svc.DeleteRuleSet(uint(id)); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"})
|
|
return
|
|
}
|
|
if h.caddyManager != nil {
|
|
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
actor := c.GetString("user_id")
|
|
if actor == "" {
|
|
actor = c.ClientIP()
|
|
}
|
|
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam})
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// Enable toggles Cerberus on, validating admin whitelist or break-glass token
|
|
func (h *SecurityHandler) Enable(c *gin.Context) {
|
|
// Look for requester's IP and optional breakglass token
|
|
adminIP := c.ClientIP()
|
|
var body struct {
|
|
Token string `json:"break_glass_token"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
|
|
// If config exists, require that adminIP is in whitelist or token matches
|
|
cfg, err := h.svc.Get()
|
|
if err != nil && err != services.ErrSecurityConfigNotFound {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve security config"})
|
|
return
|
|
}
|
|
if cfg != nil {
|
|
// Check admin whitelist
|
|
if cfg.AdminWhitelist == "" && body.Token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "admin whitelist missing; provide break_glass_token or add admin_whitelist CIDR before enabling"})
|
|
return
|
|
}
|
|
if body.Token != "" {
|
|
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
|
|
if err == nil && ok {
|
|
// proceed
|
|
} else {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
|
|
return
|
|
}
|
|
} else {
|
|
// verify client IP in admin whitelist
|
|
found := false
|
|
for _, entry := range strings.Split(cfg.AdminWhitelist, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
if entry == adminIP {
|
|
found = true
|
|
break
|
|
}
|
|
// If CIDR, check contains
|
|
if _, cidr, err := net.ParseCIDR(entry); err == nil {
|
|
if cidr.Contains(net.ParseIP(adminIP)) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// Set enabled true
|
|
newCfg := &models.SecurityConfig{Name: "default", Enabled: true}
|
|
if cfg != nil {
|
|
newCfg = cfg
|
|
newCfg.Enabled = true
|
|
}
|
|
if err := h.svc.Upsert(newCfg); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable Cerberus"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"enabled": true})
|
|
}
|
|
|
|
// Disable toggles Cerberus off; requires break-glass token or localhost request
|
|
func (h *SecurityHandler) Disable(c *gin.Context) {
|
|
var body struct {
|
|
Token string `json:"break_glass_token"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
// Allow requests from localhost to disable without token
|
|
clientIP := c.ClientIP()
|
|
if clientIP == "127.0.0.1" || clientIP == "::1" {
|
|
cfg, _ := h.svc.Get()
|
|
if cfg == nil {
|
|
cfg = &models.SecurityConfig{Name: "default", Enabled: false}
|
|
} else {
|
|
cfg.Enabled = false
|
|
}
|
|
_ = h.svc.Upsert(cfg)
|
|
c.JSON(http.StatusOK, gin.H{"enabled": false})
|
|
return
|
|
}
|
|
cfg, err := h.svc.Get()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
|
|
return
|
|
}
|
|
if body.Token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token required to disable Cerberus from non-localhost"})
|
|
return
|
|
}
|
|
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
|
|
if err != nil || !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
|
|
return
|
|
}
|
|
cfg.Enabled = false
|
|
_ = h.svc.Upsert(cfg)
|
|
c.JSON(http.StatusOK, gin.H{"enabled": false})
|
|
}
|