diff --git a/Dockerfile b/Dockerfile index af4fe847..7e4122c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,6 +122,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --with github.com/corazawaf/coraza-caddy/v2 \ --with github.com/hslatman/caddy-crowdsec-bouncer \ --with github.com/zhangjiayin/caddy-geoip2 \ + --with github.com/mholt/caddy-ratelimit \ --output /tmp/caddy-temp || true; \ # Find the build directory BUILDDIR=$(ls -td /tmp/buildenv_* 2>/dev/null | head -1); \ @@ -151,6 +152,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --with github.com/corazawaf/coraza-caddy/v2 \ --with github.com/hslatman/caddy-crowdsec-bouncer \ --with github.com/zhangjiayin/caddy-geoip2 \ + --with github.com/mholt/caddy-ratelimit \ --output /usr/bin/caddy; \ fi; \ rm -rf /tmp/buildenv_* /tmp/caddy-temp; \ diff --git a/backend/internal/api/handlers/crowdsec_decisions_test.go b/backend/internal/api/handlers/crowdsec_decisions_test.go new file mode 100644 index 00000000..3d8b48c7 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_decisions_test.go @@ -0,0 +1,450 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockCommandExecutor is a mock implementation of CommandExecutor for testing +type mockCommandExecutor struct { + output []byte + err error + calls [][]string // Track all calls made +} + +func (m *mockCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) { + call := append([]string{name}, args...) + m.calls = append(m.calls, call) + return m.output, m.err +} + +func TestListDecisions_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte(`[{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual 'ban' from 'localhost'","created_at":"2025-12-05T10:00:00Z","until":"2025-12-05T14:00:00Z"}]`), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + decisions := resp["decisions"].([]interface{}) + assert.Len(t, decisions, 1) + + decision := decisions[0].(map[string]interface{}) + assert.Equal(t, "192.168.1.100", decision["value"]) + assert.Equal(t, "ban", decision["type"]) + assert.Equal(t, "ip", decision["scope"]) + + // Verify cscli was called with correct args + require.Len(t, mockExec.calls, 1) + assert.Equal(t, []string{"cscli", "decisions", "list", "-o", "json"}, mockExec.calls[0]) +} + +func TestListDecisions_EmptyList(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte("null"), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + decisions := resp["decisions"].([]interface{}) + assert.Len(t, decisions, 0) + assert.Equal(t, float64(0), resp["total"]) +} + +func TestListDecisions_CscliError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + err: errors.New("cscli not found"), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil) + r.ServeHTTP(w, req) + + // Should return 200 with empty list and error message + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + decisions := resp["decisions"].([]interface{}) + assert.Len(t, decisions, 0) + assert.Contains(t, resp["error"], "cscli not available") +} + +func TestListDecisions_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte("invalid json"), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "failed to parse decisions") +} + +func TestBanIP_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte(""), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + payload := BanIPRequest{ + IP: "192.168.1.100", + Duration: "24h", + Reason: "suspicious activity", + } + b, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "banned", resp["status"]) + assert.Equal(t, "192.168.1.100", resp["ip"]) + assert.Equal(t, "24h", resp["duration"]) + + // Verify cscli was called with correct args + require.Len(t, mockExec.calls, 1) + assert.Equal(t, "cscli", mockExec.calls[0][0]) + assert.Equal(t, "decisions", mockExec.calls[0][1]) + assert.Equal(t, "add", mockExec.calls[0][2]) + assert.Equal(t, "-i", mockExec.calls[0][3]) + assert.Equal(t, "192.168.1.100", mockExec.calls[0][4]) + assert.Equal(t, "-d", mockExec.calls[0][5]) + assert.Equal(t, "24h", mockExec.calls[0][6]) + assert.Equal(t, "-R", mockExec.calls[0][7]) + assert.Equal(t, "manual ban: suspicious activity", mockExec.calls[0][8]) +} + +func TestBanIP_DefaultDuration(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte(""), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + payload := BanIPRequest{ + IP: "10.0.0.1", + } + b, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + // Duration should default to 24h + assert.Equal(t, "24h", resp["duration"]) + + // Verify cscli was called with default duration + require.Len(t, mockExec.calls, 1) + assert.Equal(t, "24h", mockExec.calls[0][6]) +} + +func TestBanIP_MissingIP(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + payload := map[string]string{} + b, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "ip is required") +} + +func TestBanIP_EmptyIP(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + payload := BanIPRequest{ + IP: " ", + } + b, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "ip cannot be empty") +} + +func TestBanIP_CscliError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + err: errors.New("cscli failed"), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + payload := BanIPRequest{ + IP: "192.168.1.100", + } + b, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "failed to ban IP") +} + +func TestUnbanIP_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte(""), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unbanned", resp["status"]) + assert.Equal(t, "192.168.1.100", resp["ip"]) + + // Verify cscli was called with correct args + require.Len(t, mockExec.calls, 1) + assert.Equal(t, []string{"cscli", "decisions", "delete", "-i", "192.168.1.100"}, mockExec.calls[0]) +} + +func TestUnbanIP_CscliError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + err: errors.New("cscli failed"), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "failed to unban IP") +} + +func TestListDecisions_MultipleDecisions(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + mockExec := &mockCommandExecutor{ + output: []byte(`[ + {"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual ban","created_at":"2025-12-05T10:00:00Z"}, + {"id":2,"origin":"crowdsec","type":"ban","scope":"ip","value":"10.0.0.50","duration":"1h","scenario":"ssh-bf","created_at":"2025-12-05T11:00:00Z"}, + {"id":3,"origin":"cscli","type":"ban","scope":"range","value":"172.16.0.0/24","duration":"24h","scenario":"manual ban","created_at":"2025-12-05T12:00:00Z"} + ]`), + } + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = mockExec + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + decisions := resp["decisions"].([]interface{}) + assert.Len(t, decisions, 3) + assert.Equal(t, float64(3), resp["total"]) + + // Verify each decision + d1 := decisions[0].(map[string]interface{}) + assert.Equal(t, "192.168.1.100", d1["value"]) + assert.Equal(t, "cscli", d1["origin"]) + + d2 := decisions[1].(map[string]interface{}) + assert.Equal(t, "10.0.0.50", d2["value"]) + assert.Equal(t, "crowdsec", d2["origin"]) + assert.Equal(t, "ssh-bf", d2["scenario"]) + + d3 := decisions[2].(map[string]interface{}) + assert.Equal(t, "172.16.0.0/24", d3["value"]) + assert.Equal(t, "range", d3["scope"]) +} + +func TestBanIP_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "ip is required") +} diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 2647bac1..a3b98c80 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -4,15 +4,18 @@ import ( "archive/tar" "compress/gzip" "context" + "encoding/json" "fmt" - "github.com/Wikid82/charon/backend/internal/logger" "io" "net/http" "os" + "os/exec" "path/filepath" "strings" "time" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -24,16 +27,37 @@ type CrowdsecExecutor interface { Status(ctx context.Context, configDir string) (running bool, pid int, err error) } +// CommandExecutor abstracts command execution for testing +type CommandExecutor interface { + Execute(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// RealCommandExecutor executes commands using os/exec +type RealCommandExecutor struct{} + +// Execute runs a command and returns its output +func (r *RealCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + return cmd.Output() +} + // CrowdsecHandler manages CrowdSec process and config imports. type CrowdsecHandler struct { DB *gorm.DB Executor CrowdsecExecutor + CmdExec CommandExecutor BinPath string DataDir string } func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler { - return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir} + return &CrowdsecHandler{ + DB: db, + Executor: exec, + CmdExec: &RealCommandExecutor{}, + BinPath: binPath, + DataDir: dataDir, + } } // Start starts the CrowdSec process. @@ -290,6 +314,149 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir}) } +// CrowdSecDecision represents a ban decision from CrowdSec +type CrowdSecDecision struct { + ID int64 `json:"id"` + Origin string `json:"origin"` + Type string `json:"type"` + Scope string `json:"scope"` + Value string `json:"value"` + Duration string `json:"duration"` + Scenario string `json:"scenario"` + CreatedAt time.Time `json:"created_at"` + Until string `json:"until,omitempty"` +} + +// cscliDecision represents the JSON output from cscli decisions list +type cscliDecision struct { + ID int64 `json:"id"` + Origin string `json:"origin"` + Type string `json:"type"` + Scope string `json:"scope"` + Value string `json:"value"` + Duration string `json:"duration"` + Scenario string `json:"scenario"` + CreatedAt string `json:"created_at"` + Until string `json:"until"` +} + +// ListDecisions calls cscli to get current decisions (banned IPs) +func (h *CrowdsecHandler) ListDecisions(c *gin.Context) { + ctx := c.Request.Context() + output, err := h.CmdExec.Execute(ctx, "cscli", "decisions", "list", "-o", "json") + if err != nil { + // If cscli is not available or returns error, return empty list with warning + logger.Log().WithError(err).Warn("Failed to execute cscli decisions list") + c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "error": "cscli not available or failed"}) + return + } + + // Handle empty output (no decisions) + if len(output) == 0 || string(output) == "null" || string(output) == "null\n" { + c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "total": 0}) + return + } + + // Parse JSON output + var rawDecisions []cscliDecision + if err := json.Unmarshal(output, &rawDecisions); err != nil { + logger.Log().WithError(err).WithField("output", string(output)).Warn("Failed to parse cscli decisions output") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse decisions"}) + return + } + + // Convert to our format + decisions := make([]CrowdSecDecision, 0, len(rawDecisions)) + for _, d := range rawDecisions { + var createdAt time.Time + if d.CreatedAt != "" { + createdAt, _ = time.Parse(time.RFC3339, d.CreatedAt) + } + decisions = append(decisions, CrowdSecDecision{ + ID: d.ID, + Origin: d.Origin, + Type: d.Type, + Scope: d.Scope, + Value: d.Value, + Duration: d.Duration, + Scenario: d.Scenario, + CreatedAt: createdAt, + Until: d.Until, + }) + } + + c.JSON(http.StatusOK, gin.H{"decisions": decisions, "total": len(decisions)}) +} + +// BanIPRequest represents the request body for banning an IP +type BanIPRequest struct { + IP string `json:"ip" binding:"required"` + Duration string `json:"duration"` + Reason string `json:"reason"` +} + +// BanIP adds a manual ban for an IP address +func (h *CrowdsecHandler) BanIP(c *gin.Context) { + var req BanIPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) + return + } + + // Validate IP format (basic check) + ip := strings.TrimSpace(req.IP) + if ip == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip cannot be empty"}) + return + } + + // Default duration to 24h if not specified + duration := req.Duration + if duration == "" { + duration = "24h" + } + + // Build reason string + reason := "manual ban" + if req.Reason != "" { + reason = fmt.Sprintf("manual ban: %s", req.Reason) + } + + ctx := c.Request.Context() + args := []string{"decisions", "add", "-i", ip, "-d", duration, "-R", reason, "-t", "ban"} + _, err := h.CmdExec.Execute(ctx, "cscli", args...) + if err != nil { + logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions add") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ban IP"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "banned", "ip": ip, "duration": duration}) +} + +// UnbanIP removes a ban for an IP address +func (h *CrowdsecHandler) UnbanIP(c *gin.Context) { + ip := c.Param("ip") + if ip == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip parameter required"}) + return + } + + // Sanitize IP + ip = strings.TrimSpace(ip) + + ctx := c.Request.Context() + args := []string{"decisions", "delete", "-i", ip} + _, err := h.CmdExec.Execute(ctx, "cscli", args...) + if err != nil { + logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions delete") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unban IP"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "unbanned", "ip": ip}) +} + // RegisterRoutes registers crowdsec admin routes under protected group func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) { rg.POST("/admin/crowdsec/start", h.Start) @@ -300,4 +467,8 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/admin/crowdsec/files", h.ListFiles) rg.GET("/admin/crowdsec/file", h.ReadFile) rg.POST("/admin/crowdsec/file", h.WriteFile) + // Decision management endpoints (Banned IP Dashboard) + rg.GET("/admin/crowdsec/decisions", h.ListDecisions) + rg.POST("/admin/crowdsec/ban", h.BanIP) + rg.DELETE("/admin/crowdsec/ban/:ip", h.UnbanIP) } diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 6cdb1775..4e08e6a4 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -706,18 +706,25 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er return nil, nil } -// buildCrowdSecHandler returns a placeholder CrowdSec handler. In a future -// implementation this can be replaced with a proper Caddy plugin integration -// to call into a local CrowdSec agent. +// buildCrowdSecHandler returns a CrowdSec handler for the caddy-crowdsec-bouncer plugin. +// The plugin expects api_url and optionally api_key fields. +// For local mode, we use the local LAPI address at http://localhost:8080. func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig, crowdsecEnabled bool) (Handler, error) { // Only add a handler when the computed runtime flag indicates CrowdSec is enabled. - // The computed flag incorporates runtime overrides and global Cerberus enablement. if !crowdsecEnabled { return nil, nil } - // For now, the local-only mode is supported; crowdsecEnabled implies 'local' + h := Handler{"handler": "crowdsec"} - h["mode"] = "local" + + // caddy-crowdsec-bouncer expects api_url and api_key + // For local mode, use the local LAPI address + if secCfg != nil && secCfg.CrowdSecAPIURL != "" { + h["api_url"] = secCfg.CrowdSecAPIURL + } else { + h["api_url"] = "http://localhost:8080" + } + return h, nil } @@ -817,15 +824,30 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, return h, nil } -// buildRateLimitHandler returns a placeholder for a rate-limit handler. -// Real implementation should use the relevant Caddy module/plugin when available. +// buildRateLimitHandler returns a rate-limit handler using the caddy-ratelimit module. +// The module is registered as http.handlers.rate_limit and expects: +// - handler: "rate_limit" +// - rate_limits: map of named rate limit zones with key, window, and max_events +// See: https://github.com/mholt/caddy-ratelimit +// +// Note: The rateLimitEnabled flag is already checked by the caller (GenerateConfig). +// This function only validates that the config has positive request/window values. func buildRateLimitHandler(host *models.ProxyHost, secCfg *models.SecurityConfig) (Handler, error) { - // If host has custom rate limit metadata we could parse and construct it. + if secCfg == nil { + return nil, nil + } + if secCfg.RateLimitRequests <= 0 || secCfg.RateLimitWindowSec <= 0 { + return nil, nil + } + + // caddy-ratelimit format h := Handler{"handler": "rate_limit"} - if secCfg != nil && secCfg.RateLimitRequests > 0 && secCfg.RateLimitWindowSec > 0 { - h["requests"] = secCfg.RateLimitRequests - h["window_sec"] = secCfg.RateLimitWindowSec - h["burst"] = secCfg.RateLimitBurst + h["rate_limits"] = map[string]interface{}{ + "static": map[string]interface{}{ + "key": "{http.request.remote.host}", + "window": fmt.Sprintf("%ds", secCfg.RateLimitWindowSec), + "max_events": secCfg.RateLimitRequests, + }, } return h, nil } diff --git a/backend/internal/caddy/config_crowdsec_test.go b/backend/internal/caddy/config_crowdsec_test.go new file mode 100644 index 00000000..27818eea --- /dev/null +++ b/backend/internal/caddy/config_crowdsec_test.go @@ -0,0 +1,164 @@ +package caddy + +import ( + "encoding/json" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCrowdSecHandler_Disabled(t *testing.T) { + // When crowdsecEnabled is false, should return nil + h, err := buildCrowdSecHandler(nil, nil, false) + require.NoError(t, err) + assert.Nil(t, h) +} + +func TestBuildCrowdSecHandler_EnabledWithoutConfig(t *testing.T) { + // When crowdsecEnabled is true but no secCfg, should use default localhost URL + h, err := buildCrowdSecHandler(nil, nil, true) + require.NoError(t, err) + require.NotNil(t, h) + + assert.Equal(t, "crowdsec", h["handler"]) + assert.Equal(t, "http://localhost:8080", h["api_url"]) +} + +func TestBuildCrowdSecHandler_EnabledWithEmptyAPIURL(t *testing.T) { + // When crowdsecEnabled is true but CrowdSecAPIURL is empty, should use default + secCfg := &models.SecurityConfig{ + CrowdSecAPIURL: "", + } + h, err := buildCrowdSecHandler(nil, secCfg, true) + require.NoError(t, err) + require.NotNil(t, h) + + assert.Equal(t, "crowdsec", h["handler"]) + assert.Equal(t, "http://localhost:8080", h["api_url"]) +} + +func TestBuildCrowdSecHandler_EnabledWithCustomAPIURL(t *testing.T) { + // When crowdsecEnabled is true and CrowdSecAPIURL is set, should use custom URL + secCfg := &models.SecurityConfig{ + CrowdSecAPIURL: "http://crowdsec-lapi:8081", + } + h, err := buildCrowdSecHandler(nil, secCfg, true) + require.NoError(t, err) + require.NotNil(t, h) + + assert.Equal(t, "crowdsec", h["handler"]) + assert.Equal(t, "http://crowdsec-lapi:8081", h["api_url"]) +} + +func TestBuildCrowdSecHandler_JSONFormat(t *testing.T) { + // Test that the handler produces valid JSON matching caddy-crowdsec-bouncer schema + secCfg := &models.SecurityConfig{ + CrowdSecAPIURL: "http://localhost:8080", + } + h, err := buildCrowdSecHandler(nil, secCfg, true) + require.NoError(t, err) + require.NotNil(t, h) + + // Marshal to JSON and verify structure + b, err := json.Marshal(h) + require.NoError(t, err) + s := string(b) + + // Verify expected JSON content + assert.Contains(t, s, `"handler":"crowdsec"`) + assert.Contains(t, s, `"api_url":"http://localhost:8080"`) + // Should NOT contain old "mode" field + assert.NotContains(t, s, `"mode"`) +} + +func TestBuildCrowdSecHandler_WithHost(t *testing.T) { + // Test that host parameter is accepted (even if not currently used) + host := &models.ProxyHost{ + UUID: "test-uuid", + DomainNames: "example.com", + } + secCfg := &models.SecurityConfig{ + CrowdSecAPIURL: "http://custom-crowdsec:8080", + } + + h, err := buildCrowdSecHandler(host, secCfg, true) + require.NoError(t, err) + require.NotNil(t, h) + + assert.Equal(t, "crowdsec", h["handler"]) + assert.Equal(t, "http://custom-crowdsec:8080", h["api_url"]) +} + +func TestGenerateConfig_WithCrowdSec(t *testing.T) { + // Test that CrowdSec handler is included in generated config when enabled + hosts := []models.ProxyHost{ + { + UUID: "test-uuid", + DomainNames: "example.com", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + }, + } + + secCfg := &models.SecurityConfig{ + CrowdSecMode: "local", + CrowdSecAPIURL: "http://localhost:8080", + } + + // crowdsecEnabled=true should include the handler + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, true, false, false, false, "", nil, nil, nil, secCfg) + require.NoError(t, err) + require.NotNil(t, config.Apps.HTTP) + + server := config.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server) + require.Len(t, server.Routes, 1) + + route := server.Routes[0] + // Handlers should include crowdsec + reverse_proxy + require.GreaterOrEqual(t, len(route.Handle), 2) + + // Find the crowdsec handler + var foundCrowdSec bool + for _, h := range route.Handle { + if h["handler"] == "crowdsec" { + foundCrowdSec = true + // Verify it has api_url + assert.Equal(t, "http://localhost:8080", h["api_url"]) + break + } + } + require.True(t, foundCrowdSec, "crowdsec handler should be present") +} + +func TestGenerateConfig_CrowdSecDisabled(t *testing.T) { + // Test that CrowdSec handler is NOT included when disabled + hosts := []models.ProxyHost{ + { + UUID: "test-uuid", + DomainNames: "example.com", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + }, + } + + // crowdsecEnabled=false should NOT include the handler + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, config.Apps.HTTP) + + server := config.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server) + require.Len(t, server.Routes, 1) + + route := server.Routes[0] + + // Verify no crowdsec handler + for _, h := range route.Handle { + assert.NotEqual(t, "crowdsec", h["handler"], "crowdsec handler should not be present when disabled") + } +} diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go index 62f6ef70..7ee0c455 100644 --- a/backend/internal/caddy/config_extra_test.go +++ b/backend/internal/caddy/config_extra_test.go @@ -225,7 +225,8 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) { // Provide rulesets and paths so WAF handler is created with directives rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}} rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"} - secCfg := &models.SecurityConfig{CrowdSecMode: "local"} + // Set rate limit values so rate_limit handler is included (uses caddy-ratelimit format) + secCfg := &models.SecurityConfig{CrowdSecMode: "local", RateLimitRequests: 100, RateLimitWindowSec: 60} cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, secCfg) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go index 675070af..039ee623 100644 --- a/backend/internal/caddy/config_generate_additional_test.go +++ b/backend/internal/caddy/config_generate_additional_test.go @@ -53,7 +53,8 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) { // Provide rulesets and paths so WAF handler is created with directives rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}} rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"} - sec := &models.SecurityConfig{CrowdSecMode: "local"} + // Set rate limit values so rate_limit handler is included (uses caddy-ratelimit format) + sec := &models.SecurityConfig{CrowdSecMode: "local", RateLimitRequests: 100, RateLimitWindowSec: 60} cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, sec) require.NoError(t, err) @@ -364,15 +365,20 @@ func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) { found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "rate_limit" { - if req, ok := h["requests"].(int); ok && req == 10 { - if win, ok := h["window_sec"].(int); ok && win == 60 { - found = true - break + // Check caddy-ratelimit format: rate_limits.static.max_events and window + if rateLimits, ok := h["rate_limits"].(map[string]interface{}); ok { + if static, ok := rateLimits["static"].(map[string]interface{}); ok { + if maxEvents, ok := static["max_events"].(int); ok && maxEvents == 10 { + if window, ok := static["window"].(string); ok && window == "60s" { + found = true + break + } + } } } } } - require.True(t, found, "rate_limit handler with configured values should be present") + require.True(t, found, "rate_limit handler with caddy-ratelimit format should be present") } func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) { @@ -384,13 +390,14 @@ func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) { found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "crowdsec" { - if mode, ok := h["mode"].(string); ok && mode == "local" { + // caddy-crowdsec-bouncer expects api_url field + if apiURL, ok := h["api_url"].(string); ok && apiURL == "http://cs.local" { found = true break } } } - require.True(t, found, "crowdsec handler with api_url and mode should be present") + require.True(t, found, "crowdsec handler with api_url should be present") } func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) { diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index a837cb3e..43bb4588 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -311,3 +311,133 @@ func TestBuildACLHandler_AdminWhitelistParsing(t *testing.T) { require.Contains(t, s2, "1.2.3.0/24") require.Contains(t, s2, "192.168.0.1/32") } + +func TestBuildRateLimitHandler_Disabled(t *testing.T) { + // Test nil secCfg returns nil handler + h, err := buildRateLimitHandler(nil, nil) + require.NoError(t, err) + require.Nil(t, h) +} + +func TestBuildRateLimitHandler_InvalidValues(t *testing.T) { + // Test zero requests returns nil handler + secCfg := &models.SecurityConfig{ + RateLimitRequests: 0, + RateLimitWindowSec: 60, + } + h, err := buildRateLimitHandler(nil, secCfg) + require.NoError(t, err) + require.Nil(t, h) + + // Test zero window returns nil handler + secCfg2 := &models.SecurityConfig{ + RateLimitRequests: 100, + RateLimitWindowSec: 0, + } + h, err = buildRateLimitHandler(nil, secCfg2) + require.NoError(t, err) + require.Nil(t, h) + + // Test negative values returns nil handler + secCfg3 := &models.SecurityConfig{ + RateLimitRequests: -1, + RateLimitWindowSec: 60, + } + h, err = buildRateLimitHandler(nil, secCfg3) + require.NoError(t, err) + require.Nil(t, h) +} + +func TestBuildRateLimitHandler_ValidConfig(t *testing.T) { + // Test valid configuration produces correct caddy-ratelimit format + secCfg := &models.SecurityConfig{ + RateLimitRequests: 100, + RateLimitWindowSec: 60, + } + h, err := buildRateLimitHandler(nil, secCfg) + require.NoError(t, err) + require.NotNil(t, h) + + // Verify handler type + require.Equal(t, "rate_limit", h["handler"]) + + // Verify rate_limits structure + rateLimits, ok := h["rate_limits"].(map[string]interface{}) + require.True(t, ok, "rate_limits should be a map") + + staticZone, ok := rateLimits["static"].(map[string]interface{}) + require.True(t, ok, "static zone should be a map") + + // Verify caddy-ratelimit specific fields + require.Equal(t, "{http.request.remote.host}", staticZone["key"]) + require.Equal(t, "60s", staticZone["window"]) + require.Equal(t, 100, staticZone["max_events"]) +} + +func TestBuildRateLimitHandler_JSONFormat(t *testing.T) { + // Test that the handler produces valid JSON matching caddy-ratelimit schema + secCfg := &models.SecurityConfig{ + RateLimitRequests: 30, + RateLimitWindowSec: 10, + } + h, err := buildRateLimitHandler(nil, secCfg) + require.NoError(t, err) + require.NotNil(t, h) + + // Marshal to JSON and verify structure + b, err := json.Marshal(h) + require.NoError(t, err) + s := string(b) + + // Verify expected JSON content + require.Contains(t, s, `"handler":"rate_limit"`) + require.Contains(t, s, `"rate_limits"`) + require.Contains(t, s, `"static"`) + require.Contains(t, s, `"key":"{http.request.remote.host}"`) + require.Contains(t, s, `"window":"10s"`) + require.Contains(t, s, `"max_events":30`) +} + +func TestGenerateConfig_WithRateLimiting(t *testing.T) { + // Test that rate limiting is included in generated config when enabled + hosts := []models.ProxyHost{ + { + UUID: "test-uuid", + DomainNames: "example.com", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + }, + } + + secCfg := &models.SecurityConfig{ + RateLimitEnable: true, + RateLimitRequests: 60, + RateLimitWindowSec: 60, + } + + // rateLimitEnabled=true should include the handler + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, true, false, "", nil, nil, nil, secCfg) + require.NoError(t, err) + require.NotNil(t, config.Apps.HTTP) + + server := config.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server) + require.Len(t, server.Routes, 1) + + route := server.Routes[0] + // Handlers should include rate_limit + reverse_proxy + require.GreaterOrEqual(t, len(route.Handle), 2) + + // Find the rate_limit handler + var foundRateLimit bool + for _, h := range route.Handle { + if h["handler"] == "rate_limit" { + foundRateLimit = true + // Verify it has the correct structure + require.NotNil(t, h["rate_limits"]) + break + } + } + require.True(t, foundRateLimit, "rate_limit handler should be present") +} diff --git a/docs/plans/security_features_spec.md b/docs/plans/security_features_spec.md new file mode 100644 index 00000000..61140aa5 --- /dev/null +++ b/docs/plans/security_features_spec.md @@ -0,0 +1,396 @@ +# 📋 Plan: Security Features Deep Dive - Issues #17, #18, #19 + +**Created**: December 5, 2025 +**Status**: Analysis Complete - Implementation Assessment + +--- + +## 🧐 Executive Summary + +After a comprehensive analysis of the CrowdSec (#17), WAF (#18), and Rate Limiting (#19) features, the findings show that **all three features are substantially implemented** with working frontend UIs, backend APIs, and Caddy integration. However, each has specific gaps that need to be addressed for full production readiness. + +--- + +## 📊 Implementation Status Matrix + +| Feature | Backend | Frontend | Caddy Integration | Testing | Status | +|---------|---------|----------|-------------------|---------|--------| +| **CrowdSec (#17)** | ✅ 90% | ✅ 90% | ⚠️ 70% | ⚠️ 50% | Near Complete | +| **WAF (#18)** | ✅ 95% | ✅ 95% | ✅ 85% | ✅ 80% | Near Complete | +| **Rate Limiting (#19)** | ⚠️ 60% | ✅ 90% | ⚠️ 40% | ⚠️ 30% | Needs Work | + +--- + +## 🔍 Issue #17: CrowdSec Integration (Critical) + +### What's Implemented ✅ + +**Backend:** +- CrowdSec handler (`crowdsec_handler.go`) with: + - Start/Stop process control via `CrowdsecExecutor` interface + - Status monitoring endpoint + - Import/Export configuration (tar.gz) + - File listing/reading/writing for config files + - Routes registered at `/admin/crowdsec/*` + +- Security handler integration: + - `security.crowdsec.mode` setting (disabled/local) + - `security.crowdsec.enabled` runtime override + - CrowdSec enabled flag computed in `computeEffectiveFlags()` + +**Frontend:** +- `CrowdSecConfig.tsx` page with: + - Mode selection (disabled/local) + - Import configuration (file upload) + - Export configuration (download) + - File editor for config files + - Loading states and error handling + +**Docker:** +- CrowdSec binary installed at `/usr/local/bin/crowdsec` +- Config directory at `/app/data/crowdsec` +- `caddy-crowdsec-bouncer` plugin compiled into Caddy + +### Gaps Identified ❌ + +1. **Banned IP Dashboard** - Not implemented + - Need `/api/v1/crowdsec/decisions` endpoint to list banned IPs + - Need frontend UI to display and manage banned IPs + +2. **Manual IP Ban/Unban** - Partially implemented + - `SecurityDecision` model exists but manual CrowdSec bans not wired + - Need `/api/v1/crowdsec/ban` and `/api/v1/crowdsec/unban` endpoints + +3. **Scenario/Collection Management** - Not implemented + - No UI for managing CrowdSec scenarios or collections + - Backend would need to interact with CrowdSec CLI or API + +4. **CrowdSec Log Parsing Setup** - Not implemented + - Need to configure CrowdSec to parse Caddy logs + - Acquisition config not auto-generated + +5. **Caddy Integration Handler** - Placeholder only + - `buildCrowdSecHandler()` returns `Handler{"handler": "crowdsec"}` but Caddy's `caddy-crowdsec-bouncer` expects different configuration: + ```json + { + "handler": "crowdsec", + "api_url": "http://localhost:8080", + "api_key": "..." + } + ``` + +### Acceptance Criteria Assessment + +| Criteria | Status | +|----------|--------| +| CrowdSec blocks malicious IPs automatically | ⚠️ Partial - bouncer configured but handler incomplete | +| Banned IPs visible in dashboard | ❌ Not implemented | +| Can manually ban/unban IPs | ⚠️ Partial - backend exists but not wired | +| CrowdSec status visible | ✅ Implemented | + +--- + +## 🔍 Issue #18: WAF Integration (High Priority) + +### What's Implemented ✅ + +**Backend:** +- `SecurityRuleSet` model for storing WAF rules +- `SecurityConfig.WAFMode` (disabled/monitor/block) +- `SecurityConfig.WAFRulesSource` for ruleset selection +- `buildWAFHandler()` generates Coraza handler config: + ```go + h := Handler{"handler": "waf"} + h["directives"] = fmt.Sprintf("Include %s", rulesetPath) + ``` +- Ruleset files written to `/app/data/caddy/coraza/rulesets/` +- `SecRuleEngine On/DetectionOnly` auto-prepended based on mode +- Security service CRUD for rulesets + +**Frontend:** +- `WafConfig.tsx` with: + - Rule set CRUD (create, edit, delete) + - Mode selection (blocking/detection) + - WAF presets (OWASP CRS, SQLi protection, XSS protection, Bad Bots) + - Source URL or inline content support + - Rule count display + +**Docker:** +- `coraza-caddy/v2` plugin compiled into Caddy + +**Testing:** +- Integration test `coraza_integration_test.go` +- Unit tests for WAF handler building + +### Gaps Identified ❌ + +1. **WAF Logging and Alerts** - Partially implemented + - Coraza logs to Caddy but not parsed/displayed in UI + - No WAF-specific notifications + +2. **WAF Statistics Dashboard** - Not implemented + - Need metrics collection (requests blocked, attack types) + - Prometheus metrics defined in docs but not implemented + +3. **Paranoia Level Selector** - Not implemented + - OWASP CRS paranoia levels (1-4) not exposed in UI + - Would need `SecAction "id:900000,setvar:tx.paranoia_level=2"` + +4. **Per-Host WAF Toggle** - Partially implemented + - `host.AdvancedConfig` can reference `ruleset_name` but no UI + - Need checkbox in ProxyHostForm for "Enable WAF" + +5. **Rule Exclusion System** - Not implemented + - No UI for excluding specific rules that cause false positives + - Would need `SecRuleRemoveById` directive management + +### Acceptance Criteria Assessment + +| Criteria | Status | +|----------|--------| +| WAF blocks common attacks (SQLi, XSS) | ✅ Working with Coraza | +| Can enable/disable per host | ⚠️ Via advanced config only | +| False positives manageable | ❌ No exclusion UI | +| WAF events logged and visible | ⚠️ Logs exist but not in UI | + +--- + +## 🔍 Issue #19: Rate Limiting (High Priority) + +### What's Implemented ✅ + +**Backend:** +- `SecurityConfig` model fields: + ```go + RateLimitEnable bool + RateLimitBurst int + RateLimitRequests int + RateLimitWindowSec int + ``` +- `security.rate_limit.enabled` setting +- `buildRateLimitHandler()` generates config: + ```go + h := Handler{"handler": "rate_limit"} + h["requests"] = secCfg.RateLimitRequests + h["window_sec"] = secCfg.RateLimitWindowSec + h["burst"] = secCfg.RateLimitBurst + ``` + +**Frontend:** +- `RateLimiting.tsx` with: + - Enable/disable toggle + - Requests per second input + - Burst allowance input + - Window (seconds) input + - Save configuration + +### Gaps Identified ❌ + +1. **Caddy Rate Limit Directive** - **CRITICAL GAP** + - Caddy doesn't have a built-in `rate_limit` handler + - Need to use `caddy-ratelimit` module or Caddy's `respond` with headers + - Current handler is a no-op placeholder + +2. **Rate Limit Presets** - Not implemented + - Issue specifies presets: login, API, standard + - Need predefined configurations + +3. **Per-IP Rate Limiting** - Not implemented correctly + - Handler exists but Caddy module not compiled in + - Need `github.com/mholt/caddy-ratelimit` in Dockerfile + +4. **Per-Endpoint Rate Limits** - Not implemented + - No UI for path-specific rate limits + - Would need rate limit rules per route + +5. **Bypass List (Trusted IPs)** - Not implemented + - Admin whitelist exists but not connected to rate limiting + +6. **Rate Limit Violation Logging** - Not implemented + - No logging when rate limits are hit + +7. **Rate Limit Testing Tool** - Not implemented + - No way to test rate limits from UI + +### Acceptance Criteria Assessment + +| Criteria | Status | +|----------|--------| +| Rate limits prevent brute force | ❌ Handler is placeholder | +| Presets work correctly | ❌ Not implemented | +| Legitimate traffic not affected | ⚠️ No bypass list | +| Rate limit hits logged | ❌ Not implemented | + +--- + +## 🤝 Handoff Contracts (API Specifications) + +### CrowdSec Banned IPs API + +```json +// GET /api/v1/crowdsec/decisions +{ + "response": { + "decisions": [ + { + "id": "uuid", + "ip": "192.168.1.100", + "reason": "ssh-bf", + "duration": "4h", + "created_at": "2025-12-05T10:00:00Z", + "source": "crowdsec" + } + ], + "total": 15 + } +} + +// POST /api/v1/crowdsec/ban +{ + "request": { + "ip": "192.168.1.100", + "duration": "24h", + "reason": "Manual ban - suspicious activity" + }, + "response": { + "success": true, + "decision_id": "uuid" + } +} + +// DELETE /api/v1/crowdsec/ban/:ip +{ + "response": { + "success": true + } +} +``` + +### Rate Limit Caddy Integration Fix + +The rate limit handler needs to output proper Caddy JSON: + +```json +// Correct Caddy rate_limit handler format (requires caddy-ratelimit module) +{ + "handler": "rate_limit", + "rate_limits": { + "static": { + "match": [{"method": ["GET", "POST"]}], + "key": "{http.request.remote.host}", + "window": "1m", + "max_events": 60 + } + } +} +``` + +--- + +## 🏗️ Implementation Phases + +### Phase 1: Rate Limiting Fix (Critical - Blocking Beta) + +**Backend Changes:** +1. Add `github.com/mholt/caddy-ratelimit` to Dockerfile xcaddy build +2. Fix `buildRateLimitHandler()` to output correct Caddy JSON format +3. Add rate limit bypass using admin whitelist + +**Frontend Changes:** +1. Add presets dropdown (Login: 5/min, API: 100/min, Standard: 30/min) +2. Add bypass IP list input (reuse admin whitelist) + +### Phase 2: CrowdSec Completeness (High Priority) + +**Backend Changes:** +1. Create `/api/v1/crowdsec/decisions` endpoint (call cscli) +2. Create `/api/v1/crowdsec/ban` and `unban` endpoints +3. Fix `buildCrowdSecHandler()` to include proper bouncer config +4. Auto-generate acquisition.yaml for Caddy log parsing + +**Frontend Changes:** +1. Add "Banned IPs" tab to CrowdSecConfig page +2. Add "Ban IP" button with duration selector +3. Add "Unban" action to each banned IP row + +### Phase 3: WAF Enhancements (Medium Priority) + +**Backend Changes:** +1. Add paranoia level to SecurityConfig model +2. Add rule exclusion list to SecurityRuleSet model +3. Parse Coraza logs for WAF events + +**Frontend Changes:** +1. Add paranoia level slider (1-4) to WAF config +2. Add "Enable WAF" checkbox to ProxyHostForm +3. Add rule exclusion UI (list of rule IDs to exclude) +4. Add WAF events log viewer + +### Phase 4: Testing & QA + +1. Create integration tests for each feature +2. Add E2E tests for security flows +3. Manual penetration testing + +--- + +## 🕵️ QA & Security Considerations + +### CrowdSec Security +- Ensure API key not exposed in logs +- Validate IP inputs to prevent injection +- Rate limit the ban/unban endpoints themselves + +### WAF Security +- Validate ruleset content (no malicious directives) +- Prevent path traversal in ruleset file paths +- Test for WAF bypass techniques + +### Rate Limiting Security +- Prevent bypass via IP spoofing (X-Forwarded-For) +- Ensure rate limits apply to all methods +- Test distributed rate limiting behavior + +--- + +## 📚 Documentation Updates Needed + +1. Update `docs/cerberus.md` with actual implementation status +2. Update `docs/security.md` user guide with new features +3. Add rate limiting configuration guide +4. Add CrowdSec setup wizard documentation + +--- + +## 🎯 Priority Order + +1. **Rate Limiting Caddy Module** - Blocking issue, handler is no-op +2. **CrowdSec Banned IP Dashboard** - High visibility feature +3. **WAF Per-Host Toggle** - User expectation from issue +4. **CrowdSec Manual Ban/Unban** - Security operations feature +5. **WAF Rule Exclusions** - False positive management +6. **Rate Limit Presets** - UX improvement + +--- + +## Summary: What Works vs What Doesn't + +### ✅ Working Now +- WAF rule management and blocking (Coraza integration) +- CrowdSec process control (start/stop/status) +- CrowdSec config import/export +- Rate limiting UI and settings storage +- Security status API reporting + +### ⚠️ Partially Working +- CrowdSec bouncer (handler exists but config incomplete) +- Per-host WAF (via advanced config only) +- Rate limiting settings (stored but not enforced) + +### ❌ Not Working / Missing +- Rate limiting actual enforcement (Caddy module missing) +- CrowdSec banned IP dashboard +- Manual IP ban/unban +- WAF rule exclusions +- Rate limit presets +- WAF paranoia levels diff --git a/frontend/src/api/crowdsec.ts b/frontend/src/api/crowdsec.ts index 2e75c990..5ce31da1 100644 --- a/frontend/src/api/crowdsec.ts +++ b/frontend/src/api/crowdsec.ts @@ -1,5 +1,14 @@ import client from './client' +export interface CrowdSecDecision { + id: string + ip: string + reason: string + duration: string + created_at: string + source: string +} + export async function startCrowdsec() { const resp = await client.post('/admin/crowdsec/start') return resp.data @@ -44,4 +53,17 @@ export async function writeCrowdsecFile(path: string, content: string) { return resp.data } -export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } +export async function listCrowdsecDecisions(): Promise<{ decisions: CrowdSecDecision[] }> { + const resp = await client.get<{ decisions: CrowdSecDecision[] }>('/admin/crowdsec/decisions') + return resp.data +} + +export async function banIP(ip: string, duration: string, reason: string): Promise { + await client.post('/admin/crowdsec/ban', { ip, duration, reason }) +} + +export async function unbanIP(ip: string): Promise { + await client.delete(`/admin/crowdsec/ban/${encodeURIComponent(ip)}`) +} + +export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP } diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index c81e70d8..8c364c18 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -1,19 +1,24 @@ import { useState } from 'react' import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' +import { Input } from '../components/ui/Input' import { getSecurityStatus } from '../api/security' -import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } from '../api/crowdsec' +import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec' import { createBackup } from '../api/backups' import { updateSetting } from '../api/settings' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../utils/toast' import { ConfigReloadOverlay } from '../components/LoadingStates' +import { Shield, ShieldOff, Trash2 } from 'lucide-react' export default function CrowdSecConfig() { const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus }) const [file, setFile] = useState(null) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) + const [showBanModal, setShowBanModal] = useState(false) + const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' }) + const [confirmUnban, setConfirmUnban] = useState(null) const queryClient = useQueryClient() const backupMutation = useMutation({ mutationFn: () => createBackup() }) @@ -35,6 +40,38 @@ export default function CrowdSecConfig() { const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } }) const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) }) + // Banned IPs queries and mutations + const decisionsQuery = useQuery({ + queryKey: ['crowdsec-decisions'], + queryFn: listCrowdsecDecisions, + enabled: status?.crowdsec?.mode !== 'disabled', + }) + + const banMutation = useMutation({ + mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason), + onSuccess: () => { + toast.success(`IP ${banForm.ip} has been banned`) + queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) + setShowBanModal(false) + setBanForm({ ip: '', duration: '24h', reason: '' }) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to ban IP') + }, + }) + + const unbanMutation = useMutation({ + mutationFn: (ip: string) => unbanIP(ip), + onSuccess: (_, ip) => { + toast.success(`IP ${ip} has been unbanned`) + queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) + setConfirmUnban(null) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to unban IP') + }, + }) + const handleExport = async () => { try { const blob = await exportCrowdsecConfig() @@ -88,7 +125,9 @@ export default function CrowdSecConfig() { importMutation.isPending || writeMutation.isPending || updateModeMutation.isPending || - backupMutation.isPending + backupMutation.isPending || + banMutation.isPending || + unbanMutation.isPending // Determine contextual message const getMessage = () => { @@ -101,6 +140,12 @@ export default function CrowdSecConfig() { if (updateModeMutation.isPending) { return { message: 'Three heads turn...', submessage: 'CrowdSec mode updating' } } + if (banMutation.isPending) { + return { message: 'Guardian raises shield...', submessage: 'Banning IP address' } + } + if (unbanMutation.isPending) { + return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' } + } return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' } } @@ -175,7 +220,159 @@ export default function CrowdSecConfig() { + + {/* Banned IPs Section */} + +
+
+
+ +

Banned IPs

+
+ +
+ + {status.crowdsec.mode === 'disabled' ? ( +

Enable CrowdSec to manage banned IPs

+ ) : decisionsQuery.isLoading ? ( +

Loading banned IPs...

+ ) : decisionsQuery.error ? ( +

Failed to load banned IPs

+ ) : !decisionsQuery.data?.decisions?.length ? ( +

No banned IPs

+ ) : ( +
+ + + + + + + + + + + + + {decisionsQuery.data.decisions.map((decision) => ( + + + + + + + + + ))} + +
IPReasonDurationBanned AtSourceActions
{decision.ip}{decision.reason || '-'}{decision.duration} + {decision.created_at ? new Date(decision.created_at).toLocaleString() : '-'} + {decision.source || 'manual'} + +
+
+ )} +
+
+ + {/* Ban IP Modal */} + {showBanModal && ( +
+
setShowBanModal(false)} /> +
+

+ + Ban IP Address +

+
+ setBanForm({ ...banForm, ip: e.target.value })} + /> +
+ + +
+
+ +