diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index acd31c44..5bc85409 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -260,7 +260,7 @@ func main() { } // Register import handler with config dependencies - routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile) + routes.RegisterImportHandler(router, db, cfg, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile) // Check for mounted Caddyfile on startup if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil { diff --git a/backend/internal/api/handlers/additional_coverage_test.go b/backend/internal/api/handlers/additional_coverage_test.go index a0181092..63b95a1f 100644 --- a/backend/internal/api/handlers/additional_coverage_test.go +++ b/backend/internal/api/handlers/additional_coverage_test.go @@ -170,6 +170,7 @@ func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + setAdminContext(c) c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -190,6 +191,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + setAdminContext(c) c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody) h.GenerateBreakGlass(c) @@ -252,6 +254,7 @@ func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + setAdminContext(c) c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -277,6 +280,7 @@ func TestSecurityHandler_CreateDecision_LogError(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + setAdminContext(c) c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") @@ -297,6 +301,7 @@ func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + setAdminContext(c) c.Params = gin.Params{{Key: "id", Value: "999"}} h.DeleteRuleSet(c) diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index af233532..78d94aa7 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -93,6 +93,10 @@ func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) { // GetStatus returns current import session status. func (h *ImportHandler) GetStatus(c *gin.Context) { + if !requireAuthenticatedAdmin(c) { + return + } + var session models.ImportSession err := h.db.Where("status IN ?", []string{"pending", "reviewing"}). Order("created_at DESC"). @@ -155,6 +159,10 @@ func (h *ImportHandler) GetStatus(c *gin.Context) { // GetPreview returns parsed hosts and conflicts for review. func (h *ImportHandler) GetPreview(c *gin.Context) { + if !requireAuthenticatedAdmin(c) { + return + } + var session models.ImportSession err := h.db.Where("status IN ?", []string{"pending", "reviewing"}). Order("created_at DESC"). diff --git a/backend/internal/api/handlers/permission_helpers.go b/backend/internal/api/handlers/permission_helpers.go index 6a10a353..e2a06716 100644 --- a/backend/internal/api/handlers/permission_helpers.go +++ b/backend/internal/api/handlers/permission_helpers.go @@ -24,6 +24,17 @@ func requireAdmin(c *gin.Context) bool { return false } +func requireAuthenticatedAdmin(c *gin.Context) bool { + if _, exists := c.Get("userID"); !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Authorization header required", + }) + return false + } + + return requireAdmin(c) +} + func isAdmin(c *gin.Context) bool { role, _ := c.Get("role") roleStr, _ := role.(string) diff --git a/backend/internal/api/handlers/security_geoip_endpoints_test.go b/backend/internal/api/handlers/security_geoip_endpoints_test.go index 086fc5bb..7d79f2af 100644 --- a/backend/internal/api/handlers/security_geoip_endpoints_test.go +++ b/backend/internal/api/handlers/security_geoip_endpoints_test.go @@ -59,6 +59,10 @@ func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) { h := NewSecurityHandler(config.SecurityConfig{}, nil, nil) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) r.POST("/security/geoip/reload", h.ReloadGeoIP) w := httptest.NewRecorder() @@ -75,6 +79,10 @@ func TestSecurityHandler_ReloadGeoIP_LoadError(t *testing.T) { h.SetGeoIPService(&services.GeoIPService{}) // dbPath empty => Load() will error r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) r.POST("/security/geoip/reload", h.ReloadGeoIP) w := httptest.NewRecorder() @@ -90,6 +98,10 @@ func TestSecurityHandler_LookupGeoIP_MissingIPAddress(t *testing.T) { h := NewSecurityHandler(config.SecurityConfig{}, nil, nil) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) r.POST("/security/geoip/lookup", h.LookupGeoIP) payload := []byte(`{}`) @@ -109,6 +121,10 @@ func TestSecurityHandler_LookupGeoIP_ServiceUnavailable(t *testing.T) { h.SetGeoIPService(&services.GeoIPService{}) // present but not loaded r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) r.POST("/security/geoip/lookup", h.LookupGeoIP) payload, _ := json.Marshal(map[string]string{"ip_address": "8.8.8.8"}) diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index d8dee492..4468d4b2 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -261,6 +261,10 @@ func (h *SecurityHandler) GetConfig(c *gin.Context) { // UpdateConfig creates or updates the SecurityConfig in DB func (h *SecurityHandler) UpdateConfig(c *gin.Context) { + if !requireAdmin(c) { + return + } + var payload models.SecurityConfig if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) @@ -290,6 +294,10 @@ func (h *SecurityHandler) UpdateConfig(c *gin.Context) { // GenerateBreakGlass generates a break-glass token and returns the plaintext token once func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) { + if !requireAdmin(c) { + return + } + token, err := h.svc.GenerateBreakGlassToken("default") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"}) @@ -316,6 +324,10 @@ func (h *SecurityHandler) ListDecisions(c *gin.Context) { // CreateDecision creates a manual decision (override) - for now no checks besides payload func (h *SecurityHandler) CreateDecision(c *gin.Context) { + if !requireAdmin(c) { + return + } + var payload models.SecurityDecision if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) @@ -371,6 +383,10 @@ func (h *SecurityHandler) ListRuleSets(c *gin.Context) { // UpsertRuleSet uploads or updates a ruleset func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { + if !requireAdmin(c) { + return + } + var payload models.SecurityRuleSet if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) @@ -401,6 +417,10 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { // DeleteRuleSet removes a ruleset by id func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { + if !requireAdmin(c) { + return + } + idParam := c.Param("id") if idParam == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) @@ -610,6 +630,10 @@ func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) { // ReloadGeoIP reloads the GeoIP database from disk. func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) { + if !requireAdmin(c) { + return + } + if h.geoipSvc == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "GeoIP service not initialized", @@ -641,6 +665,10 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) { // LookupGeoIP performs a GeoIP lookup for a given IP address. func (h *SecurityHandler) LookupGeoIP(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req struct { IPAddress string `json:"ip_address" binding:"required"` } @@ -707,6 +735,10 @@ func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) { // AddWAFExclusion adds a rule exclusion to the WAF configuration func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req WAFExclusionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"}) @@ -786,6 +818,10 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) { // DeleteWAFExclusion removes a rule exclusion by rule_id func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) { + if !requireAdmin(c) { + return + } + ruleIDParam := c.Param("rule_id") if ruleIDParam == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"}) diff --git a/backend/internal/api/handlers/security_handler_audit_test.go b/backend/internal/api/handlers/security_handler_audit_test.go index 5ba7251a..47d13c2f 100644 --- a/backend/internal/api/handlers/security_handler_audit_test.go +++ b/backend/internal/api/handlers/security_handler_audit_test.go @@ -100,6 +100,10 @@ func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/api/v1/security/decisions", h.CreateDecision) // Attempt SQL injection via payload fields @@ -143,6 +147,10 @@ func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) // Try to submit a 3MB payload (should be rejected by service) @@ -175,6 +183,10 @@ func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) payload := map[string]any{ @@ -203,6 +215,10 @@ func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/api/v1/security/decisions", h.CreateDecision) testCases := []struct { @@ -347,6 +363,10 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet) testCases := []struct { @@ -388,6 +408,10 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) router.GET("/api/v1/security/rulesets", h.ListRuleSets) @@ -433,6 +457,10 @@ func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) { h := NewSecurityHandler(cfg, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.PUT("/api/v1/security/config", h.UpdateConfig) testCases := []struct { diff --git a/backend/internal/api/handlers/security_handler_authz_test.go b/backend/internal/api/handlers/security_handler_authz_test.go new file mode 100644 index 00000000..32c6bf8a --- /dev/null +++ b/backend/internal/api/handlers/security_handler_authz_test.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func TestSecurityHandler_MutatorsRequireAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("userID", uint(123)) + c.Set("role", "user") + c.Next() + }) + + router.POST("/security/config", handler.UpdateConfig) + router.POST("/security/breakglass/generate", handler.GenerateBreakGlass) + router.POST("/security/decisions", handler.CreateDecision) + router.POST("/security/rulesets", handler.UpsertRuleSet) + router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet) + + testCases := []struct { + name string + method string + url string + body string + }{ + {name: "update-config", method: http.MethodPost, url: "/security/config", body: `{"name":"default"}`}, + {name: "generate-breakglass", method: http.MethodPost, url: "/security/breakglass/generate", body: `{}`}, + {name: "create-decision", method: http.MethodPost, url: "/security/decisions", body: `{"ip":"1.2.3.4","action":"block"}`}, + {name: "upsert-ruleset", method: http.MethodPost, url: "/security/rulesets", body: `{"name":"owasp-crs","mode":"block","content":"x"}`}, + {name: "delete-ruleset", method: http.MethodDelete, url: "/security/rulesets/1", body: ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.url, bytes.NewBufferString(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + }) + } +} diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go index 31ab8c2e..5019a34b 100644 --- a/backend/internal/api/handlers/security_handler_clean_test.go +++ b/backend/internal/api/handlers/security_handler_clean_test.go @@ -120,6 +120,10 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) { db := setupTestDB(t) handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/breakglass/generate", handler.GenerateBreakGlass) w := httptest.NewRecorder() @@ -251,6 +255,10 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T) handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) api := router.Group("/api/v1") api.POST("/security/enable", handler.Enable) api.POST("/security/disable", handler.Disable) diff --git a/backend/internal/api/handlers/security_handler_coverage_test.go b/backend/internal/api/handlers/security_handler_coverage_test.go index 49b83837..7ab25de7 100644 --- a/backend/internal/api/handlers/security_handler_coverage_test.go +++ b/backend/internal/api/handlers/security_handler_coverage_test.go @@ -27,6 +27,10 @@ func TestSecurityHandler_UpdateConfig_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/config", handler.UpdateConfig) payload := map[string]any{ @@ -55,6 +59,10 @@ func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/config", handler.UpdateConfig) // Payload without name - should default to "default" @@ -78,6 +86,10 @@ func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/config", handler.UpdateConfig) w := httptest.NewRecorder() @@ -193,6 +205,10 @@ func TestSecurityHandler_CreateDecision_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/decisions", handler.CreateDecision) payload := map[string]any{ @@ -218,6 +234,10 @@ func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/decisions", handler.CreateDecision) payload := map[string]any{ @@ -240,6 +260,10 @@ func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/decisions", handler.CreateDecision) payload := map[string]any{ @@ -262,6 +286,10 @@ func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/decisions", handler.CreateDecision) w := httptest.NewRecorder() @@ -306,6 +334,10 @@ func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/rulesets", handler.UpsertRuleSet) payload := map[string]any{ @@ -330,6 +362,10 @@ func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/rulesets", handler.UpsertRuleSet) payload := map[string]any{ @@ -353,6 +389,10 @@ func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/rulesets", handler.UpsertRuleSet) w := httptest.NewRecorder() @@ -375,6 +415,10 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet) w := httptest.NewRecorder() @@ -395,6 +439,10 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet) w := httptest.NewRecorder() @@ -411,6 +459,10 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet) w := httptest.NewRecorder() @@ -427,6 +479,10 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) // Note: This route pattern won't match empty ID, but testing the handler directly router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet) @@ -509,6 +565,10 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/breakglass/generate", handler.GenerateBreakGlass) router.POST("/security/enable", handler.Enable) @@ -600,6 +660,10 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/breakglass/generate", handler.GenerateBreakGlass) router.POST("/security/disable", func(c *gin.Context) { c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP @@ -689,6 +753,10 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/breakglass/generate", handler.GenerateBreakGlass) w := httptest.NewRecorder() diff --git a/backend/internal/api/handlers/security_handler_rules_decisions_test.go b/backend/internal/api/handlers/security_handler_rules_decisions_test.go index 7dcc17b2..b8de1568 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -30,6 +30,10 @@ func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) { require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) api := r.Group("/api/v1") cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) @@ -148,6 +152,10 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) { m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) api := r.Group("/api/v1") cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, m) diff --git a/backend/internal/api/handlers/security_handler_waf_test.go b/backend/internal/api/handlers/security_handler_waf_test.go index 26eb3ee9..9f338b06 100644 --- a/backend/internal/api/handlers/security_handler_waf_test.go +++ b/backend/internal/api/handlers/security_handler_waf_test.go @@ -110,6 +110,10 @@ func TestSecurityHandler_AddWAFExclusion_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) payload := map[string]any{ @@ -140,6 +144,10 @@ func TestSecurityHandler_AddWAFExclusion_WithTarget(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) payload := map[string]any{ @@ -175,6 +183,10 @@ func TestSecurityHandler_AddWAFExclusion_ToExistingConfig(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) router.GET("/security/waf/exclusions", handler.GetWAFExclusions) @@ -215,6 +227,10 @@ func TestSecurityHandler_AddWAFExclusion_Duplicate(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) // Try to add duplicate @@ -244,6 +260,10 @@ func TestSecurityHandler_AddWAFExclusion_DuplicateWithDifferentTarget(t *testing handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) // Add same rule_id with different target - should succeed @@ -268,6 +288,10 @@ func TestSecurityHandler_AddWAFExclusion_MissingRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) payload := map[string]any{ @@ -290,6 +314,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) // Zero rule_id @@ -313,6 +341,10 @@ func TestSecurityHandler_AddWAFExclusion_NegativeRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) payload := map[string]any{ @@ -335,6 +367,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidPayload(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) w := httptest.NewRecorder() @@ -358,6 +394,10 @@ func TestSecurityHandler_DeleteWAFExclusion_Success(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) router.GET("/security/waf/exclusions", handler.GetWAFExclusions) @@ -394,6 +434,10 @@ func TestSecurityHandler_DeleteWAFExclusion_WithTarget(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) router.GET("/security/waf/exclusions", handler.GetWAFExclusions) @@ -430,6 +474,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NotFound(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) w := httptest.NewRecorder() @@ -446,6 +494,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NoConfig(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) w := httptest.NewRecorder() @@ -462,6 +514,10 @@ func TestSecurityHandler_DeleteWAFExclusion_InvalidRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) w := httptest.NewRecorder() @@ -478,6 +534,10 @@ func TestSecurityHandler_DeleteWAFExclusion_ZeroRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) w := httptest.NewRecorder() @@ -494,6 +554,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) w := httptest.NewRecorder() @@ -533,6 +597,10 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) { handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) router.GET("/security/waf/exclusions", handler.GetWAFExclusions) router.POST("/security/waf/exclusions", handler.AddWAFExclusion) router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion) diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index d2eca5a6..8d39ad43 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -75,14 +75,43 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) { } // Convert to map for easier frontend consumption - settingsMap := make(map[string]string) + settingsMap := make(map[string]any) for _, s := range settings { + if isSensitiveSettingKey(s.Key) { + hasSecret := strings.TrimSpace(s.Value) != "" + settingsMap[s.Key] = "********" + settingsMap[s.Key+".has_secret"] = hasSecret + settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339) + continue + } + settingsMap[s.Key] = s.Value } c.JSON(http.StatusOK, settingsMap) } +func isSensitiveSettingKey(key string) bool { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + + sensitiveFragments := []string{ + "password", + "secret", + "token", + "api_key", + "apikey", + "webhook", + } + + for _, fragment := range sensitiveFragments { + if strings.Contains(normalizedKey, fragment) { + return true + } + } + + return false +} + type UpdateSettingRequest struct { Key string `json:"key" binding:"required"` Value string `json:"value" binding:"required"` diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index f64f4340..34d1b9ac 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -182,6 +182,31 @@ func TestSettingsHandler_GetSettings(t *testing.T) { assert.Equal(t, "test_value", response["test_key"]) } +func TestSettingsHandler_GetSettings_MasksSensitiveValues(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + db.Create(&models.Setting{Key: "smtp_password", Value: "super-secret-password", Category: "smtp", Type: "string"}) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.GET("/settings", handler.GetSettings) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/settings", http.NoBody) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]any + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "********", response["smtp_password"]) + assert.Equal(t, true, response["smtp_password.has_secret"]) + _, hasRaw := response["super-secret-password"] + assert.False(t, hasRaw) +} + func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 18fc2726..e7d82ded 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -189,7 +189,12 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"api_key": apiKey}) + c.JSON(http.StatusOK, gin.H{ + "message": "API key regenerated successfully", + "has_api_key": true, + "api_key_masked": maskSecretForResponse(apiKey), + "api_key_updated": time.Now().UTC().Format(time.RFC3339), + }) } // GetProfile returns the current user's profile including API key. @@ -207,11 +212,12 @@ func (h *UserHandler) GetProfile(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "role": user.Role, - "api_key": user.APIKey, + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "has_api_key": strings.TrimSpace(user.APIKey) != "", + "api_key_masked": maskSecretForResponse(user.APIKey), }) } @@ -548,14 +554,14 @@ func (h *UserHandler) InviteUser(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{ - "id": user.ID, - "uuid": user.UUID, - "email": user.Email, - "role": user.Role, - "invite_token": inviteToken, // Return token in case email fails - "invite_url": inviteURL, - "email_sent": emailSent, - "expires_at": inviteExpires, + "id": user.ID, + "uuid": user.UUID, + "email": user.Email, + "role": user.Role, + "invite_token_masked": maskSecretForResponse(inviteToken), + "invite_url": redactInviteURL(inviteURL), + "email_sent": emailSent, + "expires_at": inviteExpires, }) } @@ -862,16 +868,32 @@ func (h *UserHandler) ResendInvite(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "id": user.ID, - "uuid": user.UUID, - "email": user.Email, - "role": user.Role, - "invite_token": inviteToken, - "email_sent": emailSent, - "expires_at": inviteExpires, + "id": user.ID, + "uuid": user.UUID, + "email": user.Email, + "role": user.Role, + "invite_token_masked": maskSecretForResponse(inviteToken), + "email_sent": emailSent, + "expires_at": inviteExpires, }) } +func maskSecretForResponse(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + return "********" +} + +func redactInviteURL(inviteURL string) string { + if strings.TrimSpace(inviteURL) == "" { + return "" + } + + return "[REDACTED]" +} + // UpdateUserPermissions updates a user's permission mode and host exceptions (admin only). func (h *UserHandler) UpdateUserPermissions(c *gin.Context) { role, _ := c.Get("role") diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 49b53995..f62a583e 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -162,15 +162,16 @@ func TestUserHandler_RegenerateAPIKey(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - var resp map[string]string + var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["api_key"]) + assert.Equal(t, "API key regenerated successfully", resp["message"]) + assert.Equal(t, "********", resp["api_key_masked"]) // Verify DB var updatedUser models.User db.First(&updatedUser, user.ID) - assert.Equal(t, resp["api_key"], updatedUser.APIKey) + assert.NotEmpty(t, updatedUser.APIKey) } func TestUserHandler_GetProfile(t *testing.T) { @@ -1376,7 +1377,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) { var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) assert.Equal(t, "", resp["invite_url"]) // email_sent is false because no SMTP is configured assert.Equal(t, false, resp["email_sent"].(bool)) @@ -1500,7 +1501,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) { var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) assert.Equal(t, "", resp["invite_url"]) assert.Equal(t, false, resp["email_sent"].(bool)) } @@ -1553,8 +1554,8 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - token := resp["invite_token"].(string) - assert.Equal(t, "https://charon.example.com/accept-invite?token="+token, resp["invite_url"]) + assert.Equal(t, "********", resp["invite_token_masked"]) + assert.Equal(t, "[REDACTED]", resp["invite_url"]) assert.Equal(t, true, resp["email_sent"].(bool)) } @@ -1606,7 +1607,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) assert.Equal(t, "", resp["invite_url"]) assert.Equal(t, false, resp["email_sent"].(bool)) } @@ -1668,7 +1669,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T) var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) } // Note: TestGetBaseURL and TestGetAppName have been removed as these internal helper @@ -2372,8 +2373,7 @@ func TestResendInvite_Success(t *testing.T) { var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) - assert.NotEqual(t, "oldtoken123", resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) assert.Equal(t, "pending-user@example.com", resp["email"]) assert.Equal(t, false, resp["email_sent"].(bool)) // No SMTP configured @@ -2381,7 +2381,7 @@ func TestResendInvite_Success(t *testing.T) { var updatedUser models.User db.First(&updatedUser, user.ID) assert.NotEqual(t, "oldtoken123", updatedUser.InviteToken) - assert.Equal(t, resp["invite_token"], updatedUser.InviteToken) + assert.NotEmpty(t, updatedUser.InviteToken) } func TestResendInvite_WithExpiredInvite(t *testing.T) { @@ -2419,8 +2419,7 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) { var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") - assert.NotEmpty(t, resp["invite_token"]) - assert.NotEqual(t, "expiredtoken", resp["invite_token"]) + assert.Equal(t, "********", resp["invite_token_masked"]) // Verify new expiration is in the future var updatedUser models.User diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3cb79109..267ac7c5 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -520,40 +520,43 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM 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) protected.GET("/security/decisions", securityHandler.ListDecisions) - protected.POST("/security/decisions", securityHandler.CreateDecision) protected.GET("/security/rulesets", securityHandler.ListRuleSets) - protected.POST("/security/rulesets", securityHandler.UpsertRuleSet) - protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet) protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets) // GeoIP endpoints protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus) - protected.POST("/security/geoip/reload", securityHandler.ReloadGeoIP) - protected.POST("/security/geoip/lookup", securityHandler.LookupGeoIP) // WAF exclusion endpoints protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions) - protected.POST("/security/waf/exclusions", securityHandler.AddWAFExclusion) - protected.DELETE("/security/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion) + + securityAdmin := protected.Group("/security") + securityAdmin.Use(middleware.RequireRole("admin")) + securityAdmin.POST("/config", securityHandler.UpdateConfig) + securityAdmin.POST("/enable", securityHandler.Enable) + securityAdmin.POST("/disable", securityHandler.Disable) + securityAdmin.POST("/breakglass/generate", securityHandler.GenerateBreakGlass) + securityAdmin.POST("/decisions", securityHandler.CreateDecision) + securityAdmin.POST("/rulesets", securityHandler.UpsertRuleSet) + securityAdmin.DELETE("/rulesets/:id", securityHandler.DeleteRuleSet) + securityAdmin.POST("/geoip/reload", securityHandler.ReloadGeoIP) + securityAdmin.POST("/geoip/lookup", securityHandler.LookupGeoIP) + securityAdmin.POST("/waf/exclusions", securityHandler.AddWAFExclusion) + securityAdmin.DELETE("/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion) // Security module enable/disable endpoints (granular control) - protected.POST("/security/acl/enable", securityHandler.EnableACL) - protected.POST("/security/acl/disable", securityHandler.DisableACL) - protected.PATCH("/security/acl", securityHandler.PatchACL) // E2E tests use PATCH - protected.POST("/security/waf/enable", securityHandler.EnableWAF) - protected.POST("/security/waf/disable", securityHandler.DisableWAF) - protected.PATCH("/security/waf", securityHandler.PatchWAF) // E2E tests use PATCH - protected.POST("/security/cerberus/enable", securityHandler.EnableCerberus) - protected.POST("/security/cerberus/disable", securityHandler.DisableCerberus) - protected.POST("/security/crowdsec/enable", securityHandler.EnableCrowdSec) - protected.POST("/security/crowdsec/disable", securityHandler.DisableCrowdSec) - protected.PATCH("/security/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH - protected.POST("/security/rate-limit/enable", securityHandler.EnableRateLimit) - protected.POST("/security/rate-limit/disable", securityHandler.DisableRateLimit) - protected.PATCH("/security/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH + securityAdmin.POST("/acl/enable", securityHandler.EnableACL) + securityAdmin.POST("/acl/disable", securityHandler.DisableACL) + securityAdmin.PATCH("/acl", securityHandler.PatchACL) // E2E tests use PATCH + securityAdmin.POST("/waf/enable", securityHandler.EnableWAF) + securityAdmin.POST("/waf/disable", securityHandler.DisableWAF) + securityAdmin.PATCH("/waf", securityHandler.PatchWAF) // E2E tests use PATCH + securityAdmin.POST("/cerberus/enable", securityHandler.EnableCerberus) + securityAdmin.POST("/cerberus/disable", securityHandler.DisableCerberus) + securityAdmin.POST("/crowdsec/enable", securityHandler.EnableCrowdSec) + securityAdmin.POST("/crowdsec/disable", securityHandler.DisableCrowdSec) + securityAdmin.PATCH("/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH + securityAdmin.POST("/rate-limit/enable", securityHandler.EnableRateLimit) + securityAdmin.POST("/rate-limit/disable", securityHandler.DisableRateLimit) + securityAdmin.PATCH("/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH // CrowdSec process management and import // Data dir for crowdsec (persisted on host via volumes) @@ -674,17 +677,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM } // RegisterImportHandler wires up import routes with config dependencies. -func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) { +func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyBinary, importDir, mountPath string) { securityService := services.NewSecurityService(db) importHandler := handlers.NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, securityService) api := router.Group("/api/v1") - importHandler.RegisterRoutes(api) + authService := services.NewAuthService(db, cfg) + authenticatedAdmin := api.Group("/") + authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin")) + importHandler.RegisterRoutes(authenticatedAdmin) // NPM Import Handler - supports Nginx Proxy Manager export format npmImportHandler := handlers.NewNPMImportHandler(db) - npmImportHandler.RegisterRoutes(api) + npmImportHandler.RegisterRoutes(authenticatedAdmin) // JSON Import Handler - supports both Charon and NPM export formats jsonImportHandler := handlers.NewJSONImportHandler(db) - jsonImportHandler.RegisterRoutes(api) + jsonImportHandler.RegisterRoutes(authenticatedAdmin) } diff --git a/backend/internal/api/routes/routes_import_test.go b/backend/internal/api/routes/routes_import_test.go index 0e8707b1..84a0010f 100644 --- a/backend/internal/api/routes/routes_import_test.go +++ b/backend/internal/api/routes/routes_import_test.go @@ -1,15 +1,20 @@ package routes_test import ( + "net/http" + "net/http/httptest" "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/api/routes" + "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" ) func setupTestImportDB(t *testing.T) *gorm.DB { @@ -27,7 +32,7 @@ func TestRegisterImportHandler(t *testing.T) { db := setupTestImportDB(t) router := gin.New() - routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile") + routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") // Verify routes are registered by checking the routes list routeInfo := router.Routes() @@ -53,3 +58,30 @@ func TestRegisterImportHandler(t *testing.T) { assert.True(t, found, "route %s should be registered", route) } } + +func TestRegisterImportHandler_AuthzGuards(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestImportDB(t) + require.NoError(t, db.AutoMigrate(&models.User{})) + + cfg := config.Config{JWTSecret: "test-secret"} + router := gin.New() + routes.RegisterImportHandler(router, db, cfg, "echo", "/tmp", "/import/Caddyfile") + + unauthReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody) + unauthW := httptest.NewRecorder() + router.ServeHTTP(unauthW, unauthReq) + assert.Equal(t, http.StatusUnauthorized, unauthW.Code) + + nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true} + require.NoError(t, db.Create(nonAdmin).Error) + authSvc := services.NewAuthService(db, cfg) + token, err := authSvc.GenerateToken(nonAdmin) + require.NoError(t, err) + + nonAdminReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/preview", http.NoBody) + nonAdminReq.Header.Set("Authorization", "Bearer "+token) + nonAdminW := httptest.NewRecorder() + router.ServeHTTP(nonAdminW, nonAdminReq) + assert.Equal(t, http.StatusForbidden, nonAdminW.Code) +} diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index ebcd8769..4e336ed7 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -103,11 +103,13 @@ func TestRegisterImportHandler(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() + cfg := config.Config{JWTSecret: "test-secret"} + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import"), &gorm.Config{}) require.NoError(t, err) // RegisterImportHandler should not panic - RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount") + RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount") // Verify import routes exist routes := router.Routes() @@ -915,10 +917,12 @@ func TestRegisterImportHandler_RoutesExist(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() + cfg := config.Config{JWTSecret: "test-secret"} + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import_routes"), &gorm.Config{}) require.NoError(t, err) - RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount") + RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount") routes := router.Routes() routeMap := make(map[string]bool) diff --git a/backend/internal/api/tests/user_smtp_audit_test.go b/backend/internal/api/tests/user_smtp_audit_test.go index 381b4c66..f27b74a9 100644 --- a/backend/internal/api/tests/user_smtp_audit_test.go +++ b/backend/internal/api/tests/user_smtp_audit_test.go @@ -100,7 +100,10 @@ func TestInviteToken_MustBeUnguessable(t *testing.T) { var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - token := resp["invite_token"].(string) + var invitedUser models.User + require.NoError(t, db.Where("email = ?", "user@test.com").First(&invitedUser).Error) + token := invitedUser.InviteToken + require.NotEmpty(t, token) // Token MUST be at least 32 chars (64 hex = 32 bytes = 256 bits) assert.GreaterOrEqual(t, len(token), 64, "Invite token must be at least 64 hex chars (256 bits)")