feat(security): add DeleteRuleSet endpoint and implement related service logic
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -91,7 +92,7 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) {
|
||||
},
|
||||
"waf": gin.H{
|
||||
"mode": h.cfg.WAFMode,
|
||||
"enabled": h.cfg.WAFMode == "enabled",
|
||||
"enabled": h.cfg.WAFMode != "" && h.cfg.WAFMode != "disabled",
|
||||
},
|
||||
"rate_limit": gin.H{
|
||||
"mode": h.cfg.RateLimitMode,
|
||||
@@ -221,6 +222,34 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -42,7 +43,9 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var decisionResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp))
|
||||
@@ -74,4 +77,16 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
|
||||
var listRsResp map[string][]map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp))
|
||||
require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1)
|
||||
|
||||
// Delete the ruleset we just created
|
||||
idFloat, ok := listRsResp["rulesets"][0]["id"].(float64)
|
||||
require.True(t, ok)
|
||||
id := int(idFloat)
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
var delResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp))
|
||||
require.Equal(t, true, delResp["deleted"].(bool))
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
protected.POST("/security/decisions", securityHandler.CreateDecision)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.POST("/security/rulesets", securityHandler.UpsertRuleSet)
|
||||
protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet)
|
||||
|
||||
// CrowdSec process management and import
|
||||
// Data dir for crowdsec (persisted on host via volumes)
|
||||
|
||||
@@ -256,11 +256,9 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
|
||||
securityHandlers = append(securityHandlers, csH)
|
||||
}
|
||||
|
||||
// WAF handler (placeholder)
|
||||
if wafEnabled {
|
||||
if wafH, err := buildWAFHandler(&host, rulesets, secCfg); err == nil && wafH != nil {
|
||||
securityHandlers = append(securityHandlers, wafH)
|
||||
}
|
||||
// WAF handler (placeholder) — add according to runtime flag
|
||||
if wafH, err := buildWAFHandler(&host, rulesets, secCfg, wafEnabled); err == nil && wafH != nil {
|
||||
securityHandlers = append(securityHandlers, wafH)
|
||||
}
|
||||
|
||||
// Rate Limit handler (placeholder)
|
||||
@@ -703,7 +701,7 @@ func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig,
|
||||
// buildWAFHandler returns a placeholder WAF handler (Coraza) configuration.
|
||||
// This is a stub; integration with a Coraza caddy plugin would be required
|
||||
// for real runtime enforcement.
|
||||
func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig) (Handler, error) {
|
||||
func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) {
|
||||
// Find a ruleset to associate with WAF; prefer name match by host.Application or default 'owasp-crs'
|
||||
var selected *models.SecurityRuleSet
|
||||
for i, r := range rulesets {
|
||||
@@ -713,6 +711,9 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet,
|
||||
}
|
||||
}
|
||||
|
||||
if !wafEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
h := Handler{"handler": "coraza"}
|
||||
if selected != nil {
|
||||
h["ruleset_name"] = selected.Name
|
||||
|
||||
@@ -275,7 +275,8 @@ func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) {
|
||||
func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool, aclEnabled bool, wafEnabled bool, rateLimitEnabled bool, crowdsecEnabled bool) {
|
||||
// Base flags from static config
|
||||
cerbEnabled = m.securityCfg.CerberusEnabled
|
||||
wafEnabled = m.securityCfg.WAFMode == "enabled"
|
||||
// WAF is enabled if explicitly set and not 'disabled' (supports 'monitor'/'block')
|
||||
wafEnabled = m.securityCfg.WAFMode != "" && m.securityCfg.WAFMode != "disabled"
|
||||
rateLimitEnabled = m.securityCfg.RateLimitMode == "enabled"
|
||||
// CrowdSec only supports 'local' mode; treat other values as disabled
|
||||
crowdsecEnabled = m.securityCfg.CrowdSecMode == "local"
|
||||
|
||||
@@ -39,7 +39,7 @@ func (c *Cerberus) IsEnabled() bool {
|
||||
if c.cfg.CrowdSecMode == "local" {
|
||||
return true
|
||||
}
|
||||
if c.cfg.WAFMode == "enabled" || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" {
|
||||
if (c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled") || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (c *Cerberus) Middleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// WAF: naive example check - block requests containing <script> in URL
|
||||
if c.cfg.WAFMode == "enabled" {
|
||||
if c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled" {
|
||||
if strings.Contains(ctx.Request.RequestURI, "<script>") {
|
||||
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "WAF: suspicious payload detected"})
|
||||
return
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestIsEnabled_ConfigTrue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsEnabled_WAFModeEnabled(t *testing.T) {
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
cfg := config.SecurityConfig{WAFMode: "block"}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func setupDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestMiddleware_WAFBlocksPayload(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
cfg := config.SecurityConfig{WAFMode: "block"}
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
// Setup gin context
|
||||
@@ -110,7 +110,7 @@ func TestMiddleware_NotEnabledSkips(t *testing.T) {
|
||||
|
||||
func TestMiddleware_WAFPassesWithNoPayload(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
cfg := config.SecurityConfig{WAFMode: "block"}
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -192,6 +192,14 @@ func (s *SecurityService) UpsertRuleSet(r *models.SecurityRuleSet) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
// Basic validations
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("rule set name required")
|
||||
}
|
||||
// Prevent huge payloads from being stored in DB (e.g., limit 2MB)
|
||||
if len(r.Content) > 2*1024*1024 {
|
||||
return fmt.Errorf("ruleset content too large")
|
||||
}
|
||||
var existing models.SecurityRuleSet
|
||||
if err := s.db.Where("name = ?", r.Name).First(&existing).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -212,6 +220,15 @@ func (s *SecurityService) UpsertRuleSet(r *models.SecurityRuleSet) error {
|
||||
return s.db.Save(&existing).Error
|
||||
}
|
||||
|
||||
// DeleteRuleSet removes a ruleset by id
|
||||
func (s *SecurityService) DeleteRuleSet(id uint) error {
|
||||
var rs models.SecurityRuleSet
|
||||
if err := s.db.First(&rs, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Delete(&rs).Error
|
||||
}
|
||||
|
||||
|
||||
// ListRuleSets returns all known rulesets
|
||||
func (s *SecurityService) ListRuleSets() ([]models.SecurityRuleSet, error) {
|
||||
|
||||
@@ -93,6 +93,40 @@ func TestSecurityService_UpsertRuleSet(t *testing.T) {
|
||||
assert.Equal(t, "owasp-crs", list[0].Name)
|
||||
}
|
||||
|
||||
func TestSecurityService_UpsertRuleSet_ContentTooLarge(t *testing.T) {
|
||||
db := setupSecurityTestDB(t)
|
||||
svc := NewSecurityService(db)
|
||||
|
||||
// Create a string slightly larger than 2MB
|
||||
large := strings.Repeat("x", 2*1024*1024+1)
|
||||
rs := &models.SecurityRuleSet{Name: "big-crs", Content: large}
|
||||
err := svc.UpsertRuleSet(rs)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSecurityService_DeleteRuleSet(t *testing.T) {
|
||||
db := setupSecurityTestDB(t)
|
||||
svc := NewSecurityService(db)
|
||||
|
||||
rs := &models.SecurityRuleSet{Name: "owasp-crs", Content: "rule: 1"}
|
||||
err := svc.UpsertRuleSet(rs)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get list and pick ID
|
||||
list, err := svc.ListRuleSets()
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(list), 1)
|
||||
|
||||
id := list[0].ID
|
||||
// Delete
|
||||
err = svc.DeleteRuleSet(id)
|
||||
assert.NoError(t, err)
|
||||
// Ensure no rulesets left
|
||||
list, err = svc.ListRuleSets()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(list))
|
||||
}
|
||||
|
||||
func TestSecurityService_Upsert_RejectExternalMode(t *testing.T) {
|
||||
db := setupSecurityTestDB(t)
|
||||
svc := NewSecurityService(db)
|
||||
|
||||
Reference in New Issue
Block a user