From 53765afd35882043999f2cb3444c866b69678db0 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 1 Dec 2025 18:10:58 +0000 Subject: [PATCH] feat(security): implement self-lockout protection and admin whitelist - Added SecurityConfig model to manage Cerberus settings including admin whitelist and break-glass token. - Introduced SecurityService for handling security configurations and token generation. - Updated Manager to check for admin whitelist before applying configurations to prevent accidental lockouts. - Enhanced frontend with hooks and API calls for managing security settings and generating break-glass tokens. - Updated documentation to include self-lockout protection measures and best practices for using Cerberus. --- .../handlers/feature_flags_handler_test.go | 26 ++ .../notification_template_handler_test.go | 117 ++++-- .../internal/api/handlers/security_handler.go | 153 ++++++- .../security_handler_additional_test.go | 69 ++++ .../handlers/security_handler_clean_test.go | 56 ++- .../api/handlers/security_handler_test.go | 320 ++++++++++++++- backend/internal/api/routes/routes.go | 7 + backend/internal/caddy/client_test.go | 2 +- backend/internal/caddy/config.go | 49 ++- .../caddy/config_buildacl_additional_test.go | 4 +- .../internal/caddy/config_buildacl_test.go | 12 +- backend/internal/caddy/config_extra_test.go | 28 +- .../caddy/config_generate_additional_test.go | 24 +- .../internal/caddy/config_generate_test.go | 2 +- backend/internal/caddy/config_test.go | 88 +++- backend/internal/caddy/manager.go | 32 +- .../internal/caddy/manager_additional_test.go | 75 +++- backend/internal/caddy/manager_test.go | 2 +- backend/internal/caddy/normalize_test.go | 386 +++++++++--------- backend/internal/caddy/validator_test.go | 2 +- backend/internal/models/security_config.go | 22 + backend/internal/services/security_service.go | 146 +++++++ .../services/security_service_test.go | 66 +++ docs/security.md | 11 + frontend/src/api/security.ts | 35 ++ frontend/src/hooks/useSecurity.ts | 60 +++ frontend/src/pages/Security.tsx | 19 + 27 files changed, 1490 insertions(+), 323 deletions(-) create mode 100644 backend/internal/api/handlers/security_handler_additional_test.go create mode 100644 backend/internal/models/security_config.go create mode 100644 backend/internal/services/security_service.go create mode 100644 backend/internal/services/security_service_test.go create mode 100644 frontend/src/hooks/useSecurity.ts diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go index 641d8b77..4000a0b6 100644 --- a/backend/internal/api/handlers/feature_flags_handler_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_test.go @@ -71,3 +71,29 @@ func TestFeatureFlags_GetAndUpdate(t *testing.T) { t.Fatalf("expected stored value 'true' got '%s'", s.Value) } } + +func TestFeatureFlags_EnvFallback(t *testing.T) { + // Ensure env fallback is used when DB not present + t.Setenv("FEATURE_CERBERUS_ENABLED", "true") + + db := OpenTestDB(t) + // Do not write any settings so DB lookup fails and env is used + h := NewFeatureFlagsHandler(db) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/feature-flags", h.GetFlags) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) + } + var flags map[string]bool + if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { + t.Fatalf("invalid json: %v", err) + } + if !flags["feature.cerberus.enabled"] { + t.Fatalf("expected feature.cerberus.enabled to be true via env fallback") + } +} diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index 8d75e3ca..fef31b5b 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -1,50 +1,83 @@ package handlers import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" - "strings" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/services" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" ) -func setupDB(t *testing.T) *gorm.DB { - db := OpenTestDB(t) - require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - return db -} - -func TestNotificationTemplateCRUD(t *testing.T) { - db := setupDB(t) - svc := services.NewNotificationService(db) - h := NewNotificationTemplateHandler(svc) - - // Create - payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}` - req := httptest.NewRequest("POST", "/", nil) - req.Body = io.NopCloser(strings.NewReader(payload)) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = req - h.Create(c) - require.Equal(t, http.StatusCreated, w.Code) - - // List - req2 := httptest.NewRequest("GET", "/", nil) - w2 := httptest.NewRecorder() - c2, _ := gin.CreateTestContext(w2) - c2.Request = req2 - h.List(c2) - require.Equal(t, http.StatusOK, w2.Code) - var list []models.NotificationTemplate - require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list)) - require.Len(t, list, 1) +func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + r := gin.New() + api := r.Group("/api/v1") + api.GET("/notifications/templates", h.List) + api.POST("/notifications/templates", h.Create) + api.PUT("/notifications/templates/:id", h.Update) + api.DELETE("/notifications/templates/:id", h.Delete) + api.POST("/notifications/templates/preview", h.Preview) + + // Create + payload := `{"name":"test","config":"{\"hello\":\"world\"}"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + var created models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + require.NotEmpty(t, created.ID) + + // List + req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications/templates", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var list []models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &list)) + require.True(t, len(list) >= 1) + + // Update + updatedPayload := `{"name":"updated","config":"{\"hello\":\"updated\"}"}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/notifications/templates/"+created.ID, strings.NewReader(updatedPayload)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var up models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &up)) + require.Equal(t, "updated", up.Name) + + // Preview by id + previewPayload := `{"template_id":"` + created.ID + `", "data": {}}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates/preview", strings.NewReader(previewPayload)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var previewResp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &previewResp)) + require.NotEmpty(t, previewResp["rendered"]) + + // Delete + req = httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/templates/"+created.ID, nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) } diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index a778e987..ab9e97bd 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "net" "net/http" "strings" @@ -8,20 +9,21 @@ import ( "gorm.io/gorm" "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 } // NewSecurityHandler creates a new SecurityHandler. func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB) *SecurityHandler { - return &SecurityHandler{ - cfg: cfg, - db: db, - } + svc := services.NewSecurityService(db) + return &SecurityHandler{cfg: cfg, db: db, svc: svc} } // GetStatus returns the current status of all security services. @@ -101,3 +103,146 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) { }, }) } + +// 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}) +} + +// 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}) +} diff --git a/backend/internal/api/handlers/security_handler_additional_test.go b/backend/internal/api/handlers/security_handler_additional_test.go new file mode 100644 index 00000000..ed44e2b2 --- /dev/null +++ b/backend/internal/api/handlers/security_handler_additional_test.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func TestSecurityHandler_GetConfigAndUpdateConfig(t *testing.T) { + t.Helper() + // Setup DB and router + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + + // Create a gin test context for GetConfig when no config exists + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/security/config", nil) + c.Request = req + h.GetConfig(c) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + // Should return config: null + if _, ok := body["config"]; !ok { + t.Fatalf("expected 'config' in response, got %v", body) + } + + // Now update config + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + payload := `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest("POST", "/security/config", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + h.UpdateConfig(c) + require.Equal(t, http.StatusOK, w.Code) + + // Now call GetConfig again and ensure config is returned + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + req = httptest.NewRequest("GET", "/security/config", nil) + c.Request = req + h.GetConfig(c) + require.Equal(t, http.StatusOK, w.Code) + var body2 map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body2)) + cfgVal, ok := body2["config"].(map[string]interface{}) + if !ok { + t.Fatalf("expected config object, got %v", body2["config"]) + } + if cfgVal["admin_whitelist"] != "127.0.0.1/32" { + t.Fatalf("unexpected admin_whitelist: %v", cfgVal["admin_whitelist"]) + } +} diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go index 9bb1f83b..28087463 100644 --- a/backend/internal/api/handlers/security_handler_clean_test.go +++ b/backend/internal/api/handlers/security_handler_clean_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" "time" + "strings" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -24,7 +25,7 @@ func setupTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("failed to open DB: %v", err) } - if err := db.AutoMigrate(&models.Setting{}); err != nil { + if err := db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}); err != nil { t.Fatalf("failed to migrate: %v", err) } return db @@ -223,3 +224,56 @@ func TestSecurityHandler_ExternalModeMappedToDisabled(t *testing.T) { assert.Equal(t, "disabled", cs["mode"].(string)) assert.Equal(t, false, cs["enabled"].(bool)) } + +func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + // Add SecurityConfig with no admin whitelist - should refuse enable + sec := models.SecurityConfig{Name: "default", Enabled: false, AdminWhitelist: ""} + if err := db.Create(&sec).Error; err != nil { + t.Fatalf("failed to create security config: %v", err) + } + + handler := NewSecurityHandler(config.SecurityConfig{}, db) + router := gin.New() + api := router.Group("/api/v1") + api.POST("/security/enable", handler.Enable) + api.POST("/security/disable", handler.Disable) + api.POST("/security/breakglass/generate", handler.GenerateBreakGlass) + + // Attempt to enable without admin whitelist should be 400 + req := httptest.NewRequest("POST", "/api/v1/security/enable", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + // Update config with admin whitelist including 127.0.0.1 + db.Model(&sec).Update("admin_whitelist", "127.0.0.1/32") + + // Enable using admin IP via X-Forwarded-For + req = httptest.NewRequest("POST", "/api/v1/security/enable", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "127.0.0.1") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + // Generate break-glass token + req = httptest.NewRequest("POST", "/api/v1/security/breakglass/generate", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var tokenResp map[string]string + err := json.Unmarshal(resp.Body.Bytes(), &tokenResp) + assert.NoError(t, err) + token := tokenResp["token"] + assert.NotEmpty(t, token) + + // Disable using token + req = httptest.NewRequest("POST", "/api/v1/security/disable", strings.NewReader(`{"break_glass_token":"`+token+`"}`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) +} diff --git a/backend/internal/api/handlers/security_handler_test.go b/backend/internal/api/handlers/security_handler_test.go index 3b46be45..2c24b7eb 100644 --- a/backend/internal/api/handlers/security_handler_test.go +++ b/backend/internal/api/handlers/security_handler_test.go @@ -1,5 +1,4 @@ //go:build ignore -// +build ignore package handlers @@ -7,16 +6,159 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" ) -// The original file had duplicated content and misplaced build tags. -// Keep a single, well-structured test to verify both enabled/disabled security states. +// Intentionally ignored by build to avoid duplicate test artifacts during initial scaffolding +// Use security_handler_clean_test.go for canonical tests. + +func setupSecurityTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + api.GET("/security/status", h.GetStatus) + api.GET("/security/config", h.GetConfig) + api.POST("/security/config", h.UpdateConfig) + api.POST("/security/enable", h.Enable) + api.POST("/security/disable", h.Disable) + api.POST("/security/breakglass/generate", h.GenerateBreakGlass) + return r, db +} + +func TestSecurityHandler_ConfigUpsertAndBreakGlass(t *testing.T) { + r, _ := setupSecurityTestRouter(t) + + body := `{"name":"default","admin_whitelist":"invalid-cidr"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + body = `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/breakglass/generate", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var tokenResp map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tokenResp)) + require.NotEmpty(t, tokenResp["token"]) +} + +func TestSecurityHandler_GetStatus(t *testing.T) { + handler := NewSecurityHandler(config.SecurityConfig{CrowdSecMode: "disabled", WAFMode: "disabled", RateLimitMode: "disabled", ACLMode: "disabled"}, nil) + router := gin.New() + router.GET("/security/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/security/status", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func setupSecurityTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + api.GET("/security/status", h.GetStatus) + api.GET("/security/config", h.GetConfig) + api.POST("/security/config", h.UpdateConfig) + api.POST("/security/enable", h.Enable) + api.POST("/security/disable", h.Disable) + api.POST("/security/breakglass/generate", h.GenerateBreakGlass) + return r, db +} + +func TestSecurityHandler_ConfigAndBreakGlassLifecycle(t *testing.T) { + r, _ := setupSecurityTestRouter(t) + + // Invalid admin whitelist + body := `{"name":"default","admin_whitelist":"invalid-cidr"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + // Now update config with a valid admin whitelist + body = `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + // Generate break-glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/breakglass/generate", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var tokenResp map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tokenResp)) + token := tokenResp["token"] + require.NotEmpty(t, token) + + // Enable using admin whitelist (X-Forwarded-For) - this should succeed + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/enable", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "127.0.0.1") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + // Disable using break glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/disable", strings.NewReader(`{"break_glass_token":"`+token+`"}`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) +} + func TestSecurityHandler_GetStatus(t *testing.T) { gin.SetMode(gin.TestMode) @@ -112,19 +254,88 @@ func TestSecurityHandler_GetStatus(t *testing.T) { }) } } -//go:build ignore -// +build ignore - -//go:build ignore -// +build ignore - package handlers -/* - File intentionally ignored/build-tagged - see security_handler_clean_test.go for tests. -*/ +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" -// EOF + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func setupSecurityTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + api.GET("/security/status", h.GetStatus) + api.GET("/security/config", h.GetConfig) + api.POST("/security/config", h.UpdateConfig) + api.POST("/security/enable", h.Enable) + api.POST("/security/disable", h.Disable) + api.POST("/security/breakglass/generate", h.GenerateBreakGlass) + return r, db +} + +func TestSecurityHandler_ConfigAndBreakGlassLifecycle(t *testing.T) { + r, _ := setupSecurityTestRouter(t) + + // Invalid admin whitelist + body := `{"name":"default","admin_whitelist":"invalid-cidr"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + // Now update config with a valid admin whitelist + body = `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + // Generate break-glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/breakglass/generate", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var tokenResp map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tokenResp)) + token := tokenResp["token"] + require.NotEmpty(t, token) + + // Enable using admin whitelist (X-Forwarded-For) - this should succeed + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/enable", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "127.0.0.1") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + // Disable using break glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/disable", strings.NewReader(`{"break_glass_token":"`+token+`"}`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) +} func TestSecurityHandler_GetStatus(t *testing.T) { gin.SetMode(gin.TestMode) @@ -223,6 +434,89 @@ func TestSecurityHandler_GetStatus(t *testing.T) { } package handlers +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/config" +) + +func setupSecurityTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + // The NewSecurityHandler above matches pattern; here we'll use the real handler + // Register the routes manually + api.GET("/security/status", h.GetStatus) + api.GET("/security/config", h.GetConfig) + api.POST("/security/config", h.UpdateConfig) + api.POST("/security/enable", h.Enable) + api.POST("/security/disable", h.Disable) + api.POST("/security/breakglass/generate", h.GenerateBreakGlass) + return r, db +} + +func TestSecurityHandler_ConfigAndBreakGlassLifecycle(t *testing.T) { + r, _ := setupSecurityTestRouter(t) + + // Invalid admin whitelist JSON - missing because we accept comma-separated CIDRs + body := `{"name":"default","admin_whitelist":"invalid-cidr"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + + // Now update config with a valid admin whitelist + body = `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) + + // Generate break-glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/breakglass/generate", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) + var tokenResp map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tokenResp)) + token := tokenResp["token"] + require.NotEmpty(t, token) + + // Enable using admin whitelist (X-Forwarded-For) - this should succeed + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/enable", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "127.0.0.1") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) + + // Disable using break glass token + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/disable", strings.NewReader(`{"break_glass_token":"`+token+`"}`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) +} +package handlers + import ( "encoding/json" "net/http" diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 67b9f645..c5082321 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -39,6 +39,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.UptimeHost{}, &models.UptimeNotificationEvent{}, &models.Domain{}, + &models.SecurityConfig{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -207,6 +208,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Security Status securityHandler := handlers.NewSecurityHandler(cfg.Security, db) protected.GET("/security/status", securityHandler.GetStatus) + // Security Config management + protected.GET("/security/config", securityHandler.GetConfig) + protected.POST("/security/config", securityHandler.UpdateConfig) + protected.POST("/security/enable", securityHandler.Enable) + protected.POST("/security/disable", securityHandler.Disable) + protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass) // CrowdSec process management and import // Data dir for crowdsec (persisted on host via volumes) diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index 35349a71..de96c091 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -31,7 +31,7 @@ func TestClient_Load_Success(t *testing.T) { ForwardPort: 8080, Enabled: true, }, - }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true) + }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "") err := client.Load(context.Background(), config) require.NoError(t, err) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 7db9db6c..c06dd5ae 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -13,7 +13,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) { // Define log file paths // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs" // storageDir is .../data/caddy/data @@ -225,7 +225,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Add Access Control List (ACL) handler if configured and global ACL is enabled if aclEnabled && host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled { - aclHandler, err := buildACLHandler(host.AccessList) + aclHandler, err := buildACLHandler(host.AccessList, adminWhitelist) if err != nil { logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host") } else if aclHandler != nil { @@ -262,7 +262,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin Path: []string{loc.Path, loc.Path + "/*"}, }, }, - Handle: locHandlers, + Handle: locHandlers, Terminal: true, } routes = append(routes, locRoute) @@ -426,7 +426,7 @@ func NormalizeAdvancedConfig(parsed interface{}) interface{} { } // buildACLHandler creates access control handlers based on the AccessList configuration -func buildACLHandler(acl *models.AccessList) (Handler, error) { +func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, error) { // For geo-blocking, we use CEL (Common Expression Language) matcher with caddy-geoip2 placeholders // For IP-based ACLs, we use Caddy's native remote_ip matcher @@ -554,6 +554,17 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) { if acl.Type == "whitelist" { // Allow only these IPs (block everything else) + // Merge adminWhitelist into allowed cidrs so that admins always bypass whitelist checks + if adminWhitelist != "" { + adminParts := strings.Split(adminWhitelist, ",") + for _, p := range adminParts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + cidrs = append(cidrs, p) + } + } return Handler{ "handler": "subroute", "routes": []map[string]interface{}{ @@ -584,17 +595,33 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) { if acl.Type == "blacklist" { // Block these IPs (allow everything else) + // For blacklist, add an explicit 'not' clause excluding adminWhitelist ranges from the match + var adminExclusion interface{} + if adminWhitelist != "" { + adminParts := strings.Split(adminWhitelist, ",") + trims := make([]string, 0) + for _, p := range adminParts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + trims = append(trims, p) + } + if len(trims) > 0 { + adminExclusion = map[string]interface{}{"not": []map[string]interface{}{{"remote_ip": map[string]interface{}{"ranges": trims}}}} + } + } + // Build matcher parts + matchParts := []map[string]interface{}{} + matchParts = append(matchParts, map[string]interface{}{"remote_ip": map[string]interface{}{"ranges": cidrs}}) + if adminExclusion != nil { + matchParts = append(matchParts, adminExclusion.(map[string]interface{})) + } return Handler{ "handler": "subroute", "routes": []map[string]interface{}{ { - "match": []map[string]interface{}{ - { - "remote_ip": map[string]interface{}{ - "ranges": cidrs, - }, - }, - }, + "match": matchParts, "handle": []map[string]interface{}{ { "handler": "static_response", diff --git a/backend/internal/caddy/config_buildacl_additional_test.go b/backend/internal/caddy/config_buildacl_additional_test.go index 8ac2121e..c084364c 100644 --- a/backend/internal/caddy/config_buildacl_additional_test.go +++ b/backend/internal/caddy/config_buildacl_additional_test.go @@ -10,7 +10,7 @@ import ( func TestBuildACLHandler_GeoBlacklist(t *testing.T) { acl := &models.AccessList{Type: "geo_blacklist", CountryCodes: "GB,FR", Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.NotNil(t, h) b, _ := json.Marshal(h) @@ -19,7 +19,7 @@ func TestBuildACLHandler_GeoBlacklist(t *testing.T) { func TestBuildACLHandler_UnknownTypeReturnsNil(t *testing.T) { acl := &models.AccessList{Type: "unknown_type", Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.Nil(t, h) } diff --git a/backend/internal/caddy/config_buildacl_test.go b/backend/internal/caddy/config_buildacl_test.go index bdd2c1fb..68caa953 100644 --- a/backend/internal/caddy/config_buildacl_test.go +++ b/backend/internal/caddy/config_buildacl_test.go @@ -10,7 +10,7 @@ import ( func TestBuildACLHandler_GeoWhitelist(t *testing.T) { acl := &models.AccessList{Type: "geo_whitelist", CountryCodes: "US,CA", Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.NotNil(t, h) @@ -21,7 +21,7 @@ func TestBuildACLHandler_GeoWhitelist(t *testing.T) { func TestBuildACLHandler_LocalNetwork(t *testing.T) { acl := &models.AccessList{Type: "whitelist", LocalNetworkOnly: true, Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.NotNil(t, h) b, _ := json.Marshal(h) @@ -31,7 +31,7 @@ func TestBuildACLHandler_LocalNetwork(t *testing.T) { func TestBuildACLHandler_IPRules(t *testing.T) { rules := `[ {"cidr": "192.168.1.0/24", "description": "local"} ]` acl := &models.AccessList{Type: "blacklist", IPRules: rules, Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.NotNil(t, h) b, _ := json.Marshal(h) @@ -40,14 +40,14 @@ func TestBuildACLHandler_IPRules(t *testing.T) { func TestBuildACLHandler_InvalidIPJSON(t *testing.T) { acl := &models.AccessList{Type: "blacklist", IPRules: `invalid-json`, Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.Error(t, err) require.Nil(t, h) } func TestBuildACLHandler_NoIPRulesReturnsNil(t *testing.T) { acl := &models.AccessList{Type: "blacklist", IPRules: `[]`, Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.Nil(t, h) } @@ -55,7 +55,7 @@ func TestBuildACLHandler_NoIPRulesReturnsNil(t *testing.T) { func TestBuildACLHandler_Whitelist(t *testing.T) { rules := `[ { "cidr": "192.168.1.0/24", "description": "local" } ]` acl := &models.AccessList{Type: "whitelist", IPRules: rules, Enabled: true} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.NotNil(t, h) b, _ := json.Marshal(h) diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go index 66dc1ae6..934aa4a6 100644 --- a/backend/internal/caddy/config_extra_test.go +++ b/backend/internal/caddy/config_extra_test.go @@ -10,7 +10,7 @@ import ( ) func TestGenerateConfig_CatchAllFrontend(t *testing.T) { - cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] require.NotNil(t, server) @@ -32,7 +32,7 @@ func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) { }, } - cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] require.NotNil(t, server) @@ -63,7 +63,7 @@ func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) { }, } - cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] require.NotNil(t, server) @@ -77,7 +77,7 @@ func TestGenerateConfig_LowercaseDomains(t *testing.T) { hosts := []models.ProxyHost{ {UUID: "d1", DomainNames: "UPPER.EXAMPLE.COM", ForwardHost: "a", ForwardPort: 80, Enabled: true}, } - cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Debug prints removed @@ -93,7 +93,7 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) { Enabled: true, AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Obj":["1"]}}}`, } - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // First handler should be headers @@ -110,7 +110,7 @@ func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) { Enabled: true, AdvancedConfig: `{"handler":"headers","request":{"set":{"Upgrade":"websocket"}},"response":{"set":{"X-Obj":"1"}}}`, } - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Debug prints removed @@ -167,10 +167,10 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) { acl := models.AccessList{ID: 100, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules} host := models.ProxyHost{UUID: "hasacl", DomainNames: "acl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl} // Sanity check: buildACLHandler should return a subroute handler for this ACL - aclH, err := buildACLHandler(&acl) + aclH, err := buildACLHandler(&acl, "") require.NoError(t, err) require.NotNil(t, aclH) - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Accept either a subroute (ACL) or reverse_proxy as first handler @@ -182,7 +182,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) { func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) { hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}} - cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] require.Equal(t, []string{"test.example.com"}, route.Match[0].Host) @@ -190,7 +190,7 @@ func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) { func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) { host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // No headers handler appended; last handler is reverse_proxy @@ -200,7 +200,7 @@ func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) { func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) { host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Expect main reverse proxy handler exists but no appended advanced handler @@ -211,7 +211,7 @@ func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) { // Test buildACLHandler returning nil when an unknown type is supplied but IPRules present func TestBuildACLHandler_UnknownIPTypeReturnsNil(t *testing.T) { acl := &models.AccessList{Type: "custom", IPRules: `[{"cidr":"10.0.0.0/8"}]`} - h, err := buildACLHandler(acl) + h, err := buildACLHandler(acl, "") require.NoError(t, err) require.Nil(t, h) } @@ -222,7 +222,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) { acl := models.AccessList{ID: 200, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules} host := models.ProxyHost{UUID: "pipeline1", DomainNames: "pipe.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "") require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] @@ -245,7 +245,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) { func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) { host := models.ProxyHost{UUID: "pipe2", DomainNames: "pipe2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "") 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 4aa94f86..e2b2db32 100644 --- a/backend/internal/caddy/config_generate_additional_test.go +++ b/backend/internal/caddy/config_generate_additional_test.go @@ -22,7 +22,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) { } // Zerossl provider - cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false) + cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "") require.NoError(t, err) require.NotNil(t, cfgZ.Apps.TLS) // Expect only zerossl issuer present @@ -37,7 +37,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) { require.True(t, foundZerossl) // Default/both provider - cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false) + cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "") require.NoError(t, err) issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw // We should have at least 2 issuers (acme + zerossl) @@ -50,7 +50,7 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) { acl := models.AccessList{ID: 201, Name: "WL2", Enabled: true, Type: "whitelist", IPRules: ipRules} host := models.ProxyHost{UUID: "pipeline2", DomainNames: "pipe-loc.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true, Locations: []models.Location{{Path: "/loc", ForwardHost: "app", ForwardPort: 9000}}} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] @@ -95,7 +95,7 @@ func TestGenerateConfig_ACLLogWarning(t *testing.T) { acl := models.AccessList{ID: 300, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid-json"} host := models.ProxyHost{UUID: "acl-log", DomainNames: "acl-err.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "") require.NoError(t, err) require.NotNil(t, cfg) @@ -107,7 +107,7 @@ func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) { ipRules := `[ { "cidr": "10.0.0.0/8" } ]` acl := models.AccessList{ID: 301, Name: "WL3", Enabled: true, Type: "whitelist", IPRules: ipRules} host := models.ProxyHost{UUID: "acl-incl", DomainNames: "acl-incl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] require.NotNil(t, server) @@ -132,7 +132,7 @@ func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) { } func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) { - cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "") require.NoError(t, err) // Should return base config without server routes _, found := cfg.Apps.HTTP.Servers["charon_server"] @@ -144,7 +144,7 @@ func TestGenerateConfig_SkipsInvalidCustomCert(t *testing.T) { cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: ""} host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: ptrUint(1)} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "") require.NoError(t, err) // Custom cert missing key should not be in LoadPEM if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { @@ -157,7 +157,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) { // Two hosts with same domain - one newer than other should be kept only once h1 := models.ProxyHost{UUID: "h1", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080} h2 := models.ProxyHost{UUID: "h2", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.2", ForwardPort: 8081} - cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] // Expect that only one route exists for dup.com (one for the domain) @@ -167,7 +167,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) { func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) { cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "LoadPEM", Provider: "custom", Certificate: "cert", PrivateKey: "key"} host := models.ProxyHost{UUID: "h1", DomainNames: "pem.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: &cert.ID} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "") require.NoError(t, err) require.NotNil(t, cfg.Apps.TLS) require.NotNil(t, cfg.Apps.TLS.Certificates) @@ -175,7 +175,7 @@ func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) { func TestGenerateConfig_DefaultAcmeStaging(t *testing.T) { hosts := []models.ProxyHost{{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}} - cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "") require.NoError(t, err) // Should include acme issuer with CA staging URL issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw @@ -196,7 +196,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) { // create host with an ACL with invalid JSON to force buildACLHandler to error acl := models.AccessList{ID: 10, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid"} host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] // Even if ACL handler error occurs, config should still be returned with routes @@ -207,7 +207,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) { func TestGenerateConfig_SkipHostDomainEmptyAndDisabled(t *testing.T) { disabled := models.ProxyHost{UUID: "h1", Enabled: false, DomainNames: "skip.com", ForwardHost: "127.0.0.1", ForwardPort: 8080} emptyDomain := models.ProxyHost{UUID: "h2", Enabled: true, DomainNames: "", ForwardHost: "127.0.0.1", ForwardPort: 8080} - cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false) + cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "") require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] // Both hosts should be skipped; only routes from no hosts should be only catch-all if frontend provided diff --git a/backend/internal/caddy/config_generate_test.go b/backend/internal/caddy/config_generate_test.go index 7773950b..740a999f 100644 --- a/backend/internal/caddy/config_generate_test.go +++ b/backend/internal/caddy/config_generate_test.go @@ -24,7 +24,7 @@ func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) { Locations: []models.Location{{Path: "/app", ForwardHost: "127.0.0.1", ForwardPort: 8081}}, }, } - cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false) + cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "") require.NoError(t, err) require.NotNil(t, cfg) // TLS should be configured diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index a187e965..d3cadd49 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -9,7 +10,7 @@ import ( ) func TestGenerateConfig_Empty(t *testing.T) { - config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true) + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "") require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -31,7 +32,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "") require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -71,7 +72,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "") require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2) } @@ -87,8 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { Enabled: true, }, } - - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "") require.NoError(t, err) route := config.Apps.HTTP.Servers["charon_server"].Routes[0] @@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "") require.NoError(t, err) // Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here) require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes) @@ -117,7 +117,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { func TestGenerateConfig_Logging(t *testing.T) { hosts := []models.ProxyHost{} - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "") require.NoError(t, err) // Verify logging configuration @@ -155,7 +155,7 @@ func TestGenerateConfig_Advanced(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "") require.NoError(t, err) require.NotNil(t, config) @@ -202,7 +202,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { } // Test with staging enabled - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "") require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) @@ -217,7 +217,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"]) // Test with staging disabled (production) - config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false) + config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "") require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) @@ -233,3 +233,71 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { require.False(t, hasCA, "Production mode should not set ca field (uses default)") // We can't easily check the map content without casting, but we know it's there. } + +func TestBuildACLHandler_WhitelistAndBlacklistAdminMerge(t *testing.T) { + // Whitelist case: ensure adminWhitelist gets merged into allowed ranges + acl := &models.AccessList{Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`} + handler, err := buildACLHandler(acl, "10.0.0.1/32") + require.NoError(t, err) + // handler should include both ranges in the remote_ip ranges + b, _ := json.Marshal(handler) + s := string(b) + require.Contains(t, s, "127.0.0.1/32") + require.Contains(t, s, "10.0.0.1/32") + + // Blacklist case: ensure adminWhitelist excluded from match + acl2 := &models.AccessList{Type: "blacklist", IPRules: `[{"cidr":"1.2.3.0/24"}]`} + handler2, err := buildACLHandler(acl2, "192.168.0.1/32") + require.NoError(t, err) + b2, _ := json.Marshal(handler2) + s2 := string(b2) + require.Contains(t, s2, "1.2.3.0/24") + require.Contains(t, s2, "192.168.0.1/32") +} + +func TestBuildACLHandler_GeoAndLocalNetwork(t *testing.T) { + // Geo whitelist + acl := &models.AccessList{Type: "geo_whitelist", CountryCodes: "US,CA"} + h, err := buildACLHandler(acl, "") + require.NoError(t, err) + b, _ := json.Marshal(h) + s := string(b) + require.Contains(t, s, "geoip2.country_code") + + // Geo blacklist + acl2 := &models.AccessList{Type: "geo_blacklist", CountryCodes: "RU"} + h2, err := buildACLHandler(acl2, "") + require.NoError(t, err) + b2, _ := json.Marshal(h2) + s2 := string(b2) + require.Contains(t, s2, "geoip2.country_code") + + // Local network only + acl3 := &models.AccessList{Type: "whitelist", LocalNetworkOnly: true} + h3, err := buildACLHandler(acl3, "") + require.NoError(t, err) + b3, _ := json.Marshal(h3) + s3 := string(b3) + require.Contains(t, s3, "10.0.0.0/8") +} + +func TestBuildACLHandler_AdminWhitelistParsing(t *testing.T) { + // Whitelist should trim and include multiple values, skip empties + acl := &models.AccessList{Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`} + handler, err := buildACLHandler(acl, " , 10.0.0.1/32, , 192.168.1.5/32 ") + require.NoError(t, err) + b, _ := json.Marshal(handler) + s := string(b) + require.Contains(t, s, "127.0.0.1/32") + require.Contains(t, s, "10.0.0.1/32") + require.Contains(t, s, "192.168.1.5/32") + + // Blacklist parsing too + acl2 := &models.AccessList{Type: "blacklist", IPRules: `[{"cidr":"1.2.3.0/24"}]`} + handler2, err := buildACLHandler(acl2, " , 192.168.0.1/32, ") + require.NoError(t, err) + b2, _ := json.Marshal(handler2) + s2 := string(b2) + require.Contains(t, s2, "1.2.3.0/24") + require.Contains(t, s2, "192.168.0.1/32") +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 5d9b1407..f4b121bf 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -1,7 +1,6 @@ package caddy import ( - "strings" "context" "crypto/sha256" "encoding/json" @@ -9,13 +8,14 @@ import ( "os" "path/filepath" "sort" + "strings" "time" "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" ) // Test hooks to allow overriding OS and JSON functions @@ -33,12 +33,12 @@ var ( // Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback. type Manager struct { - client *Client - db *gorm.DB - configDir string - frontendDir string - acmeStaging bool - securityCfg config.SecurityConfig + client *Client + db *gorm.DB + configDir string + frontendDir string + acmeStaging bool + securityCfg config.SecurityConfig } // NewManager creates a configuration manager. @@ -78,8 +78,22 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { // Compute effective security flags (re-read runtime overrides) _, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx) + // Safety check: if Cerberus is enabled in DB and no admin whitelist configured, + // block applying changes to avoid accidental self-lockout. + var secCfg models.SecurityConfig + if err := m.db.Where("name = ?", "default").First(&secCfg).Error; err == nil { + if secCfg.Enabled && strings.TrimSpace(secCfg.AdminWhitelist) == "" { + return fmt.Errorf("refusing to apply config: Cerberus is enabled but admin_whitelist is empty; add an admin whitelist entry or generate a break-glass token") + } + } + // Generate Caddy config - config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled) + // Read admin whitelist for config generation so handlers can exclude admin IPs + var adminWhitelist string + if secCfg.AdminWhitelist != "" { + adminWhitelist = secCfg.AdminWhitelist + } + config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index 50ca86ff..caf4b7bb 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -419,7 +419,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) { // stub generateConfigFunc to always return error orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) { return nil, fmt.Errorf("generate fail") } defer func() { generateConfigFunc = orig }() @@ -430,6 +430,29 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) { assert.Contains(t, err.Error(), "generate config") } +func TestManager_ApplyConfig_RejectsWhenCerberusEnabledWithoutAdminWhitelist(t *testing.T) { + tmp := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"cerberus") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + assert.NoError(t, err) + assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{})) + + // create a host so ApplyConfig would try to generate config + h := models.ProxyHost{DomainNames: "test.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true} + db.Create(&h) + + // Insert SecurityConfig with enabled=true but no whitelist + sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: ""} + assert.NoError(t, db.Create(&sec).Error) + + // Create manager and call ApplyConfig - expecting error due to safety check + client := NewClient("http://localhost:9999") + manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) + err = manager.ApplyConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "refusing to apply config: Cerberus is enabled but admin_whitelist is empty") +} + func TestManager_ApplyConfig_ValidateFails(t *testing.T) { tmp := t.TempDir() // Setup DB - minimal @@ -523,6 +546,54 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning_Stderr(t *testing.T) { assert.NoError(t, err) } +func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) { + tmp := t.TempDir() + // Setup DB - minimal + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"adminwl") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + assert.NoError(t, err) + assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{})) + + // Create a host so ApplyConfig would try to generate config + h := models.ProxyHost{DomainNames: "test.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true} + db.Create(&h) + + // Insert SecurityConfig with enabled=true and an admin whitelist + sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32"} + assert.NoError(t, db.Create(&sec).Error) + + // Setup a client server that accepts loads + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/config/" && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + w.Write([]byte("{" + "\"apps\":{\"http\":{}}}")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() + client := NewClient(caddyServer.URL) + + // Stub generateConfigFunc to capture adminWhitelist + var capturedAdmin string + orig := generateConfigFunc + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string) (*Config, error) { + capturedAdmin = adminWhitelist + // return minimal config + return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil + } + defer func() { generateConfigFunc = orig }() + + manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{}) + err = manager.ApplyConfig(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "10.0.0.1/32", capturedAdmin) +} + func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) { // Capture /load payloads loadCh := make(chan []byte, 10) diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 13362b08..8a02019f 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" diff --git a/backend/internal/caddy/normalize_test.go b/backend/internal/caddy/normalize_test.go index 80d5fe31..b70c11f9 100644 --- a/backend/internal/caddy/normalize_test.go +++ b/backend/internal/caddy/normalize_test.go @@ -1,225 +1,225 @@ package caddy import ( - "encoding/json" - "fmt" - "testing" + "encoding/json" + "fmt" + "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" ) func TestNormalizeAdvancedConfig_MapWithNestedHandles(t *testing.T) { - // Build a map with nested 'handle' array containing headers with string values - raw := map[string]interface{}{ - "handler": "subroute", - "routes": []interface{}{ - map[string]interface{}{ - "handle": []interface{}{ - map[string]interface{}{ - "handler": "headers", - "request": map[string]interface{}{ - "set": map[string]interface{}{"Upgrade": "websocket"}, - }, - "response": map[string]interface{}{ - "set": map[string]interface{}{"X-Obj": "1"}, - }, - }, - }, - }, - }, - } + // Build a map with nested 'handle' array containing headers with string values + raw := map[string]interface{}{ + "handler": "subroute", + "routes": []interface{}{ + map[string]interface{}{ + "handle": []interface{}{ + map[string]interface{}{ + "handler": "headers", + "request": map[string]interface{}{ + "set": map[string]interface{}{"Upgrade": "websocket"}, + }, + "response": map[string]interface{}{ + "set": map[string]interface{}{"X-Obj": "1"}, + }, + }, + }, + }, + }, + } - out := NormalizeAdvancedConfig(raw) - // Verify nested header values normalized - outMap, ok := out.(map[string]interface{}) - require.True(t, ok) - routes := outMap["routes"].([]interface{}) - require.Len(t, routes, 1) - r := routes[0].(map[string]interface{}) - handles := r["handle"].([]interface{}) - require.Len(t, handles, 1) - hdr := handles[0].(map[string]interface{}) + out := NormalizeAdvancedConfig(raw) + // Verify nested header values normalized + outMap, ok := out.(map[string]interface{}) + require.True(t, ok) + routes := outMap["routes"].([]interface{}) + require.Len(t, routes, 1) + r := routes[0].(map[string]interface{}) + handles := r["handle"].([]interface{}) + require.Len(t, handles, 1) + hdr := handles[0].(map[string]interface{}) - // request.set.Upgrade - req := hdr["request"].(map[string]interface{}) - set := req["set"].(map[string]interface{}) - // Could be []interface{} or []string depending on code path; normalize to []string representation - switch v := set["Upgrade"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"websocket"}, outArr) - case []string: - require.Equal(t, []string{"websocket"}, v) - default: - t.Fatalf("unexpected type for Upgrade: %T", v) - } + // request.set.Upgrade + req := hdr["request"].(map[string]interface{}) + set := req["set"].(map[string]interface{}) + // Could be []interface{} or []string depending on code path; normalize to []string representation + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } - // response.set.X-Obj - resp := hdr["response"].(map[string]interface{}) - rset := resp["set"].(map[string]interface{}) - switch v := rset["X-Obj"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"1"}, outArr) - case []string: - require.Equal(t, []string{"1"}, v) - default: - t.Fatalf("unexpected type for X-Obj: %T", v) - } + // response.set.X-Obj + resp := hdr["response"].(map[string]interface{}) + rset := resp["set"].(map[string]interface{}) + switch v := rset["X-Obj"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Obj: %T", v) + } } func TestNormalizeAdvancedConfig_ArrayTopLevel(t *testing.T) { - // Top-level array containing a headers handler with array value as []interface{} - raw := []interface{}{ - map[string]interface{}{ - "handler": "headers", - "response": map[string]interface{}{ - "set": map[string]interface{}{"X-Obj": []interface{}{"1"}}, - }, - }, - } - out := NormalizeAdvancedConfig(raw) - outArr := out.([]interface{}) - require.Len(t, outArr, 1) - hdr := outArr[0].(map[string]interface{}) - resp := hdr["response"].(map[string]interface{}) - set := resp["set"].(map[string]interface{}) - switch v := set["X-Obj"].(type) { - case []interface{}: - var outArr2 []string - for _, it := range v { - outArr2 = append(outArr2, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"1"}, outArr2) - case []string: - require.Equal(t, []string{"1"}, v) - default: - t.Fatalf("unexpected type for X-Obj: %T", v) - } + // Top-level array containing a headers handler with array value as []interface{} + raw := []interface{}{ + map[string]interface{}{ + "handler": "headers", + "response": map[string]interface{}{ + "set": map[string]interface{}{"X-Obj": []interface{}{"1"}}, + }, + }, + } + out := NormalizeAdvancedConfig(raw) + outArr := out.([]interface{}) + require.Len(t, outArr, 1) + hdr := outArr[0].(map[string]interface{}) + resp := hdr["response"].(map[string]interface{}) + set := resp["set"].(map[string]interface{}) + switch v := set["X-Obj"].(type) { + case []interface{}: + var outArr2 []string + for _, it := range v { + outArr2 = append(outArr2, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr2) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Obj: %T", v) + } } func TestNormalizeAdvancedConfig_DefaultPrimitives(t *testing.T) { - // Ensure primitive values remain unchanged - v := NormalizeAdvancedConfig(42) - require.Equal(t, 42, v) - v2 := NormalizeAdvancedConfig("hello") - require.Equal(t, "hello", v2) + // Ensure primitive values remain unchanged + v := NormalizeAdvancedConfig(42) + require.Equal(t, 42, v) + v2 := NormalizeAdvancedConfig("hello") + require.Equal(t, "hello", v2) } func TestNormalizeAdvancedConfig_CoerceNonStandardTypes(t *testing.T) { - // Use a header value that is numeric and ensure it's coerced to string - raw := map[string]interface{}{"handler": "headers", "response": map[string]interface{}{"set": map[string]interface{}{"X-Num": 1}}} - out := NormalizeAdvancedConfig(raw).(map[string]interface{}) - resp := out["response"].(map[string]interface{}) - set := resp["set"].(map[string]interface{}) - // Should be a []string with "1" - switch v := set["X-Num"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"1"}, outArr) - case []string: - require.Equal(t, []string{"1"}, v) - default: - t.Fatalf("unexpected type for X-Num: %T", v) - } + // Use a header value that is numeric and ensure it's coerced to string + raw := map[string]interface{}{"handler": "headers", "response": map[string]interface{}{"set": map[string]interface{}{"X-Num": 1}}} + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + resp := out["response"].(map[string]interface{}) + set := resp["set"].(map[string]interface{}) + // Should be a []string with "1" + switch v := set["X-Num"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Num: %T", v) + } } func TestNormalizeAdvancedConfig_JSONRoundtrip(t *testing.T) { - // Ensure normalized config can be marshaled back to JSON and unmarshaled - raw := map[string]interface{}{"handler": "headers", "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}} - out := NormalizeAdvancedConfig(raw) - b, err := json.Marshal(out) - require.NoError(t, err) - // Marshal back and read result - var parsed interface{} - require.NoError(t, json.Unmarshal(b, &parsed)) + // Ensure normalized config can be marshaled back to JSON and unmarshaled + raw := map[string]interface{}{"handler": "headers", "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}} + out := NormalizeAdvancedConfig(raw) + b, err := json.Marshal(out) + require.NoError(t, err) + // Marshal back and read result + var parsed interface{} + require.NoError(t, json.Unmarshal(b, &parsed)) } func TestNormalizeAdvancedConfig_TopLevelHeaders(t *testing.T) { - // Top-level 'headers' key should be normalized similar to request/response - raw := map[string]interface{}{ - "handler": "headers", - "headers": map[string]interface{}{ - "set": map[string]interface{}{"Upgrade": "websocket"}, - }, - } - out := NormalizeAdvancedConfig(raw).(map[string]interface{}) - hdrs := out["headers"].(map[string]interface{}) - set := hdrs["set"].(map[string]interface{}) - switch v := set["Upgrade"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"websocket"}, outArr) - case []string: - require.Equal(t, []string{"websocket"}, v) - default: - t.Fatalf("unexpected type for Upgrade: %T", v) - } + // Top-level 'headers' key should be normalized similar to request/response + raw := map[string]interface{}{ + "handler": "headers", + "headers": map[string]interface{}{ + "set": map[string]interface{}{"Upgrade": "websocket"}, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + hdrs := out["headers"].(map[string]interface{}) + set := hdrs["set"].(map[string]interface{}) + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } } func TestNormalizeAdvancedConfig_HeadersAlreadyArray(t *testing.T) { - // If the header value is already a []string it should be left as-is - raw := map[string]interface{}{ - "handler": "headers", - "headers": map[string]interface{}{ - "set": map[string]interface{}{"X-Test": []string{"a", "b"}}, - }, - } - out := NormalizeAdvancedConfig(raw).(map[string]interface{}) - hdrs := out["headers"].(map[string]interface{}) - set := hdrs["set"].(map[string]interface{}) - switch v := set["X-Test"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"a", "b"}, outArr) - case []string: - require.Equal(t, []string{"a", "b"}, v) - default: - t.Fatalf("unexpected type for X-Test: %T", v) - } + // If the header value is already a []string it should be left as-is + raw := map[string]interface{}{ + "handler": "headers", + "headers": map[string]interface{}{ + "set": map[string]interface{}{"X-Test": []string{"a", "b"}}, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + hdrs := out["headers"].(map[string]interface{}) + set := hdrs["set"].(map[string]interface{}) + switch v := set["X-Test"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"a", "b"}, outArr) + case []string: + require.Equal(t, []string{"a", "b"}, v) + default: + t.Fatalf("unexpected type for X-Test: %T", v) + } } func TestNormalizeAdvancedConfig_MapWithTopLevelHandle(t *testing.T) { - raw := map[string]interface{}{ - "handler": "subroute", - "handle": []interface{}{ - map[string]interface{}{ - "handler": "headers", - "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}, - }, - }, - } - out := NormalizeAdvancedConfig(raw).(map[string]interface{}) - handles := out["handle"].([]interface{}) - require.Len(t, handles, 1) - hdr := handles[0].(map[string]interface{}) - req := hdr["request"].(map[string]interface{}) - set := req["set"].(map[string]interface{}) - switch v := set["Upgrade"].(type) { - case []interface{}: - var outArr []string - for _, it := range v { - outArr = append(outArr, fmt.Sprintf("%v", it)) - } - require.Equal(t, []string{"websocket"}, outArr) - case []string: - require.Equal(t, []string{"websocket"}, v) - default: - t.Fatalf("unexpected type for Upgrade: %T", v) - } + raw := map[string]interface{}{ + "handler": "subroute", + "handle": []interface{}{ + map[string]interface{}{ + "handler": "headers", + "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}, + }, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + handles := out["handle"].([]interface{}) + require.Len(t, handles, 1) + hdr := handles[0].(map[string]interface{}) + req := hdr["request"].(map[string]interface{}) + set := req["set"].(map[string]interface{}) + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } } diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index 8b636ee2..d33db677 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) { }, } - config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false) + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "") err := Validate(config) require.NoError(t, err) } diff --git a/backend/internal/models/security_config.go b/backend/internal/models/security_config.go new file mode 100644 index 00000000..94da1682 --- /dev/null +++ b/backend/internal/models/security_config.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" +) + +// SecurityConfig represents global Cerberus/CrowdSec/WAF/RateLimit settings +// used by the server and propagated into the generated Caddy config. +type SecurityConfig struct { + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + Enabled bool `json:"enabled"` + AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs + BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"` + CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block" + WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block" + RateLimitEnable bool `json:"rate_limit_enable"` + RateLimitBurst int `json:"rate_limit_burst"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/services/security_service.go b/backend/internal/services/security_service.go new file mode 100644 index 00000000..41ae7c2c --- /dev/null +++ b/backend/internal/services/security_service.go @@ -0,0 +1,146 @@ +package services + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "net" + + "golang.org/x/crypto/bcrypt" + + "github.com/Wikid82/charon/backend/internal/models" + "gorm.io/gorm" +) + +var ( + ErrSecurityConfigNotFound = errors.New("security config not found") + ErrInvalidAdminCIDR = errors.New("invalid admin whitelist CIDR") + ErrBreakGlassInvalid = errors.New("break-glass token invalid") +) + +type SecurityService struct { + db *gorm.DB +} + +// NewSecurityService returns a SecurityService using the provided DB +func NewSecurityService(db *gorm.DB) *SecurityService { + return &SecurityService{db: db} +} + +// Get returns the first SecurityConfig row (singleton config) +func (s *SecurityService) Get() (*models.SecurityConfig, error) { + var cfg models.SecurityConfig + if err := s.db.First(&cfg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSecurityConfigNotFound + } + return nil, err + } + return &cfg, nil +} + +// Upsert validates and saves a security config +func (s *SecurityService) Upsert(cfg *models.SecurityConfig) error { + // Validate AdminWhitelist - comma-separated list of CIDRs + if cfg.AdminWhitelist != "" { + parts := strings.Split(cfg.AdminWhitelist, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // Validate as IP or CIDR using the same helper as AccessListService + if !isValidCIDR(p) { + return ErrInvalidAdminCIDR + } + } + } + + // If a breakglass token is present in BreakGlassHash as empty string, + // do not overwrite it here. Token generation should be done explicitly. + + // Upsert behaviour: try to find existing record + var existing models.SecurityConfig + if err := s.db.Where("name = ?", cfg.Name).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // New record + return s.db.Create(cfg).Error + } + return err + } + + // Preserve existing BreakGlassHash if not provided + if cfg.BreakGlassHash == "" { + cfg.BreakGlassHash = existing.BreakGlassHash + } + existing.Enabled = cfg.Enabled + existing.AdminWhitelist = cfg.AdminWhitelist + existing.CrowdSecMode = cfg.CrowdSecMode + existing.WAFMode = cfg.WAFMode + existing.RateLimitEnable = cfg.RateLimitEnable + existing.RateLimitBurst = cfg.RateLimitBurst + + return s.db.Save(&existing).Error +} + +// GenerateBreakGlassToken generates a token, stores its bcrypt hash, and returns the plaintext token +func (s *SecurityService) GenerateBreakGlassToken(name string) (string, error) { + tokenBytes := make([]byte, 24) + if _, err := rand.Read(tokenBytes); err != nil { + return "", err + } + token := hex.EncodeToString(tokenBytes) + + hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + var cfg models.SecurityConfig + if err := s.db.Where("name = ?", name).First(&cfg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + cfg = models.SecurityConfig{Name: name, BreakGlassHash: string(hash)} + if err := s.db.Create(&cfg).Error; err != nil { + return "", err + } + return token, nil + } + return "", err + } + + cfg.BreakGlassHash = string(hash) + if err := s.db.Save(&cfg).Error; err != nil { + return "", err + } + return token, nil +} + +// VerifyBreakGlassToken validates a provided token against the stored hash +func (s *SecurityService) VerifyBreakGlassToken(name, token string) (bool, error) { + var cfg models.SecurityConfig + if err := s.db.Where("name = ?", name).First(&cfg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, ErrSecurityConfigNotFound + } + return false, err + } + if cfg.BreakGlassHash == "" { + return false, ErrBreakGlassInvalid + } + if err := bcrypt.CompareHashAndPassword([]byte(cfg.BreakGlassHash), []byte(token)); err != nil { + return false, ErrBreakGlassInvalid + } + return true, nil +} + +// helper: reused from access_list_service validation for CIDR/IP parsing +func isValidCIDR(cidr string) bool { + // Try parsing as single IP + if ip := net.ParseIP(cidr); ip != nil { + return true + } + // Try parsing as CIDR + _, _, err := net.ParseCIDR(cidr) + return err == nil +} diff --git a/backend/internal/services/security_service_test.go b/backend/internal/services/security_service_test.go new file mode 100644 index 00000000..11cbc113 --- /dev/null +++ b/backend/internal/services/security_service_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "testing" + "strings" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupSecurityTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + assert.NoError(t, err) + + err = db.AutoMigrate(&models.SecurityConfig{}) + assert.NoError(t, err) + + return db +} + +func TestSecurityService_Upsert_ValidateAdminWhitelist(t *testing.T) { + db := setupSecurityTestDB(t) + svc := NewSecurityService(db) + + // Invalid CIDR in admin whitelist should fail + cfg := &models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "invalid-cidr"} + err := svc.Upsert(cfg) + assert.Error(t, err) + assert.Equal(t, ErrInvalidAdminCIDR, err) + + // Valid CIDR should succeed + cfg.AdminWhitelist = "192.168.1.0/24, 10.0.0.1" + err = svc.Upsert(cfg) + assert.NoError(t, err) + + // Verify stored + got, err := svc.Get() + assert.NoError(t, err) + assert.True(t, strings.Contains(got.AdminWhitelist, "192.168.1.0/24")) +} + +func TestSecurityService_BreakGlassTokenLifecycle(t *testing.T) { + db := setupSecurityTestDB(t) + svc := NewSecurityService(db) + + // Create record + cfg := &models.SecurityConfig{Name: "default", Enabled: false} + err := svc.Upsert(cfg) + assert.NoError(t, err) + + token, err := svc.GenerateBreakGlassToken("default") + assert.NoError(t, err) + assert.NotEmpty(t, token) + + // Verify valid token returns true + ok, err := svc.VerifyBreakGlassToken("default", token) + assert.NoError(t, err) + assert.True(t, ok) + + // Invalid token fails + ok, err = svc.VerifyBreakGlassToken("default", "wrongtoken") + assert.Error(t, err) + assert.False(t, ok) +} diff --git a/docs/security.md b/docs/security.md index 3a63c694..6f471f94 100644 --- a/docs/security.md +++ b/docs/security.md @@ -101,6 +101,17 @@ environment: --- +## Self-Lockout Protection + +When enabling the Cerberus suite (CrowdSec, WAF, ACLs, Rate Limiting) there is a risk of accidentally locking yourself out of the Admin UI or services you rely on. Charon provides the following safeguards to reduce this risk: + +- **Admin Whitelist**: When enabling Cerberus you should enter at least one administrative IP or CIDR range (for example your VPN IP, Tailscale IP, or a trusted office IP). This whitelist is always excluded from blocking decisions. +- **Break-Glass Token**: You can generate a temporary break-glass token from the Security UI. This one-time token (returned plaintext once) can be used to disable Cerberus if you lose access. +- **Localhost Bypass**: Requests from `127.0.0.1` or `::1` may be allowed to manage the system locally without a token (helpful for local management access). +- **Manager Checks**: Config deployment will be refused if Cerberus is enabled and no admin whitelist is configured — this prevents accidental global lockouts when applying new configurations. + +Follow a phased approach: deploy in `monitor`/`log-only` modes, validate findings, add admin whitelist entries, then switch to `block`/`enforce` mode. + ## ACL Best Practices by Service Type ### Internal Services (Pi-hole, Home Assistant, Router Admin) diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts index 1cd8e70e..44101fbd 100644 --- a/frontend/src/api/security.ts +++ b/frontend/src/api/security.ts @@ -24,3 +24,38 @@ export const getSecurityStatus = async (): Promise => { const response = await client.get('/security/status') return response.data } + +export interface SecurityConfigPayload { + name?: string + enabled?: boolean + admin_whitelist?: string + crowdsec_mode?: string + waf_mode?: string + rate_limit_enable?: boolean + rate_limit_burst?: number +} + +export const getSecurityConfig = async () => { + const response = await client.get('/security/config') + return response.data +} + +export const updateSecurityConfig = async (payload: SecurityConfigPayload) => { + const response = await client.post('/security/config', payload) + return response.data +} + +export const generateBreakGlassToken = async () => { + const response = await client.post('/security/breakglass/generate') + return response.data +} + +export const enableCerberus = async (payload?: any) => { + const response = await client.post('/security/enable', payload || {}) + return response.data +} + +export const disableCerberus = async (payload?: any) => { + const response = await client.post('/security/disable', payload || {}) + return response.data +} diff --git a/frontend/src/hooks/useSecurity.ts b/frontend/src/hooks/useSecurity.ts new file mode 100644 index 00000000..423aa491 --- /dev/null +++ b/frontend/src/hooks/useSecurity.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus } from '../api/security' +import toast from 'react-hot-toast' + +export function useSecurityStatus() { + return useQuery({ queryKey: ['securityStatus'], queryFn: getSecurityStatus }) +} + +export function useSecurityConfig() { + return useQuery({ queryKey: ['securityConfig'], queryFn: getSecurityConfig }) +} + +export function useUpdateSecurityConfig() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: any) => updateSecurityConfig(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['securityConfig'] }) + qc.invalidateQueries({ queryKey: ['securityStatus'] }) + toast.success('Security configuration updated') + }, + onError: (err: Error) => { + toast.error(`Failed to update security settings: ${err.message}`) + }, + }) +} + +export function useGenerateBreakGlassToken() { + return useMutation({ mutationFn: () => generateBreakGlassToken() }) +} + +export function useEnableCerberus() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload?: any) => enableCerberus(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['securityConfig'] }) + qc.invalidateQueries({ queryKey: ['securityStatus'] }) + toast.success('Cerberus enabled') + }, + onError: (err: Error) => { + toast.error(`Failed to enable Cerberus: ${err.message}`) + }, + }) +} + +export function useDisableCerberus() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload?: any) => disableCerberus(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['securityConfig'] }) + qc.invalidateQueries({ queryKey: ['securityStatus'] }) + toast.success('Cerberus disabled') + }, + onError: (err: Error) => { + toast.error(`Failed to disable Cerberus: ${err.message}`) + }, + }) +} diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index 0bd8f1d4..f2f42b85 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useNavigate, Outlet } from 'react-router-dom' import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react' import { getSecurityStatus } from '../api/security' +import { useSecurityConfig, useUpdateSecurityConfig, useGenerateBreakGlassToken } from '../hooks/useSecurity' import { exportCrowdsecConfig, startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec' import { updateSetting } from '../api/settings' import { Switch } from '../components/ui/Switch' @@ -16,6 +17,15 @@ export default function Security() { queryKey: ['security-status'], queryFn: getSecurityStatus, }) + const { data: securityConfig } = useSecurityConfig() + const [adminWhitelist, setAdminWhitelist] = useState('') + useEffect(() => { + if (securityConfig && securityConfig.config) { + setAdminWhitelist(securityConfig.config.admin_whitelist || '') + } + }, [securityConfig]) + const updateSecurityConfigMutation = useUpdateSecurityConfig() + const generateBreakGlassMutation = useGenerateBreakGlassToken() const queryClient = useQueryClient() const [crowdsecStatus, setCrowdsecStatus] = useState<{ running: boolean; pid?: number } | null>(null) // Generic toggle mutation for per-service settings @@ -153,6 +163,15 @@ export default function Security() { +
+ +
+ setAdminWhitelist(e.target.value)} /> + + +
+
+
{/* CrowdSec */}