diff --git a/.github/agents/Frontend_Dev.agent.md b/.github/agents/Frontend_Dev.agent.md index a9254395..8420c246 100644 --- a/.github/agents/Frontend_Dev.agent.md +++ b/.github/agents/Frontend_Dev.agent.md @@ -35,7 +35,7 @@ You do not just "make it work"; you make it **feel** professional, responsive, a 3. **Verification (Definition of Done)**: - Run `npm run lint`. - Run `npm run test` (Ensure no regressions). - - **MANDATORY**: Run `pre-commit run --all-files`. + - **MANDATORY**: Run `pre-commit run --all-files` and fix any issues immediately.. 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 920e1eed..96372440 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "strconv" + "time" + "path/filepath" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -16,13 +18,16 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/caddy" ) func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + // Use a file-backed sqlite DB to avoid shared memory connection issues in tests + dsn := filepath.Join(t.TempDir(), "test.db") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) + 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() api := r.Group("/api/v1") @@ -97,3 +102,70 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) { require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp)) require.Equal(t, true, delResp["deleted"].(bool)) } + +func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) { + t.Helper() + // Setup 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{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) + + // Ensure DB has expected tables (migrations executed above) + + // Ensure proxy_hosts table exists in case AutoMigrate didn't create it + db.Exec("CREATE TABLE IF NOT EXISTS proxy_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, domain_names TEXT, forward_host TEXT, forward_port INTEGER, enabled BOOLEAN)") + // Create minimal settings and caddy_configs tables to satisfy Manager.ApplyConfig queries + db.Exec("CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, value TEXT, type TEXT, category TEXT, updated_at datetime)") + db.Exec("CREATE TABLE IF NOT EXISTS caddy_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, config_hash TEXT, applied_at datetime, success BOOLEAN, error_msg TEXT)") + // debug: tables exist + + // Caddy admin server to capture /load calls + loadCh := make(chan struct{}, 2) + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + loadCh <- struct{}{} + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() + + client := caddy.NewClient(caddyServer.URL) + tmp := t.TempDir() + m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db, m) + api.POST("/security/rulesets", h.UpsertRuleSet) + api.DELETE("/security/rulesets/:id", h.DeleteRuleSet) + + // Upsert ruleset should trigger manager.ApplyConfig -> POST /load + rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + select { + case <-loadCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for manager ApplyConfig /load post on upsert") + } + + // Now delete the ruleset and ensure /load is triggered again + // Read ID from DB + var rs models.SecurityRuleSet + assert.NoError(t, db.First(&rs).Error) + req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(int(rs.ID)), nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + select { + case <-loadCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for manager ApplyConfig /load post on delete") + } +} diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index 98fa9bd6..b74c938c 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, "", nil, nil, nil) + }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil) 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 ced544d3..99c1eca5 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, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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 @@ -257,7 +257,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin } // WAF handler (placeholder) — add according to runtime flag - if wafH, err := buildWAFHandler(&host, rulesets, secCfg, wafEnabled); err == nil && wafH != nil { + if wafH, err := buildWAFHandler(&host, rulesets, rulesetPaths, secCfg, wafEnabled); err == nil && wafH != nil { securityHandlers = append(securityHandlers, wafH) } @@ -701,7 +701,7 @@ func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig, // buildWAFHandler returns a placeholder WAF handler (Coraza) configuration. // This is a stub; integration with a Coraza caddy plugin would be required // for real runtime enforcement. -func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) { +func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) { // Find a ruleset to associate with WAF; prefer name match by host.Application or default 'owasp-crs' var selected *models.SecurityRuleSet for i, r := range rulesets { @@ -718,6 +718,11 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, if selected != nil { h["ruleset_name"] = selected.Name h["ruleset_content"] = selected.Content + if rulesetPaths != nil { + if p, ok := rulesetPaths[selected.Name]; ok && p != "" { + h["ruleset_path"] = p + } + } } else if secCfg != nil && secCfg.WAFRulesSource != "" { // If there was a requested ruleset name but nothing matched, include it as a reference h["ruleset_name"] = secCfg.WAFRulesSource diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go index af1cde9b..1ccdea76 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Debug prints removed @@ -170,7 +170,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) { 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Expect main reverse proxy handler exists but no appended advanced handler @@ -223,7 +223,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) { host := models.ProxyHost{UUID: "pipeline1", DomainNames: "pipe.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true} secCfg := &models.SecurityConfig{CrowdSecMode: "local"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, secCfg) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, nil, secCfg) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] @@ -246,7 +246,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) 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 cef0a9f5..6d5ca180 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, "", nil, nil, nil) + cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "", nil, nil, nil, nil) 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, "", nil, nil, nil) + cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw // We should have at least 2 issuers (acme + zerossl) @@ -51,7 +51,7 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) { 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}}} sec := &models.SecurityConfig{CrowdSecMode: "local"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, nil, sec) require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] @@ -96,7 +96,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, cfg) @@ -108,7 +108,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] require.NotNil(t, server) @@ -136,7 +136,7 @@ func TestGenerateConfig_DecisionsBlockWithAdminExclusion(t *testing.T) { host := models.ProxyHost{UUID: "dec1", DomainNames: "dec.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} // create a security decision to block 1.2.3.4 dec := models.SecurityDecision{Action: "block", IP: "1.2.3.4"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, []models.SecurityDecision{dec}, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // Expect first security handler is a subroute that includes both remote_ip and a 'not' exclusion for adminWhitelist @@ -164,7 +164,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) { host := models.ProxyHost{UUID: "wafref", DomainNames: "wafref.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} // No rulesets provided but secCfg references a rulesource sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec) require.NoError(t, err) // Since a ruleset name was requested but none exists, coraza handler should include ruleset_name but no ruleset_content route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] @@ -182,7 +182,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) { // Now test learning/monitor mode mapping sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true} - cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec2) + cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec2) require.NoError(t, err) route2 := cfg2.Apps.HTTP.Servers["charon_server"].Routes[0] monitorFound := false @@ -199,7 +199,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) { func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) { host := models.ProxyHost{UUID: "waf-disabled", DomainNames: "wafd.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} sec := &models.SecurityConfig{WAFMode: "disabled", WAFRulesSource: "owasp-crs"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] for _, h := range route.Handle { @@ -213,7 +213,7 @@ func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) { host := models.ProxyHost{UUID: "waf-2", DomainNames: "waf2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"} sec := &models.SecurityConfig{WAFMode: "block"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil, sec) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false @@ -235,7 +235,7 @@ func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) { host := models.ProxyHost{UUID: "dec2", DomainNames: "dec2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} dec := models.SecurityDecision{Action: "block", IP: "2.3.4.5"} // Provide an adminWhitelist with an empty segment to trigger p == "" - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, ", 10.0.0.1/32", nil, []models.SecurityDecision{dec}, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, ", 10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false @@ -271,7 +271,7 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { // host + ruleset configured host := models.ProxyHost{UUID: "waf-1", DomainNames: "waf.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] // check coraza handler present with ruleset_name @@ -290,7 +290,7 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) { host := models.ProxyHost{UUID: "rl-1", DomainNames: "rl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} sec := &models.SecurityConfig{RateLimitRequests: 10, RateLimitWindowSec: 60, RateLimitBurst: 5} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, true, false, "", nil, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, true, false, "", nil, nil, nil, sec) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false @@ -310,7 +310,7 @@ func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) { func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) { host := models.ProxyHost{UUID: "cs-1", DomainNames: "cs.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080} sec := &models.SecurityConfig{CrowdSecMode: "local", CrowdSecAPIURL: "http://cs.local"} - cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, false, false, false, "", nil, nil, sec) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, false, false, false, "", nil, nil, nil, sec) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false @@ -326,7 +326,7 @@ func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) { } func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) { - cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) // Should return base config without server routes _, found := cfg.Apps.HTTP.Servers["charon_server"] @@ -338,7 +338,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) // Custom cert missing key should not be in LoadPEM if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { @@ -351,7 +351,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] // Expect that only one route exists for dup.com (one for the domain) @@ -361,7 +361,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, cfg.Apps.TLS) require.NotNil(t, cfg.Apps.TLS.Certificates) @@ -369,7 +369,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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) // Should include acme issuer with CA staging URL issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw @@ -390,7 +390,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) server := cfg.Apps.HTTP.Servers["charon_server"] // Even if ACL handler error occurs, config should still be returned with routes @@ -401,7 +401,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, "", nil, nil, nil) + cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil) 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 1bd1ee0e..91f6981e 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, "", nil, nil, nil) + cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil) 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 4e67d5ea..a837cb3e 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -10,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, "", nil, nil, nil) + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Apps.HTTP) require.Empty(t, config.Apps.HTTP.Servers) @@ -34,7 +34,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Apps.HTTP) require.Len(t, config.Apps.HTTP.Servers, 1) @@ -76,7 +76,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2) require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2) @@ -93,7 +93,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { Enabled: true, }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Apps.HTTP) @@ -115,7 +115,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes) // Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here) @@ -124,7 +124,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, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Logging) @@ -163,7 +163,7 @@ func TestGenerateConfig_Advanced(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config) @@ -211,7 +211,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, "", nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS) @@ -227,7 +227,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, "", nil, nil, nil) + config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "", nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 4f7dfbf7..1b17d704 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -106,7 +106,27 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { 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, rulesets, decisions, &secCfg) + // Ensure ruleset files exist on disk and build a map of their paths for GenerateConfig + rulesetPaths := make(map[string]string) + if len(rulesets) > 0 { + corazaDir := filepath.Join(m.configDir, "coraza", "rulesets") + if err := os.MkdirAll(corazaDir, 0755); err != nil { + logger.Log().WithError(err).Warn("failed to create coraza rulesets dir") + } + for _, rs := range rulesets { + // sanitize name to a safe filename + safeName := strings.ReplaceAll(strings.ToLower(rs.Name), " ", "-") + safeName = strings.ReplaceAll(safeName, "/", "-") + filePath := filepath.Join(corazaDir, safeName+".conf") + if err := writeFileFunc(filePath, []byte(rs.Content), 0600); err != nil { + logger.Log().WithError(err).WithField("ruleset", rs.Name).Warn("failed to write coraza ruleset file") + } else { + rulesetPaths[rs.Name] = filePath + } + } + } + + config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg) 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 9c71d73c..0a2628ef 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -420,7 +420,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, adminWhitelist string, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { return nil, fmt.Errorf("generate fail") } defer func() { generateConfigFunc = orig }() @@ -582,7 +582,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) // 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, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { capturedAdmin = adminWhitelist // return minimal config return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil @@ -633,7 +633,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) { var capturedRules []models.SecurityRuleSet 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, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { capturedRules = rulesets return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil } @@ -688,10 +688,10 @@ func TestManager_ApplyConfig_IncludesCorazaHandlerWithRuleset(t *testing.T) { var capturedWafEnabled bool var capturedRulesets []models.SecurityRuleSet origGen := 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, rulesets []models.SecurityRuleSet, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { capturedWafEnabled = wafEnabled capturedRulesets = rulesets - return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, decisions, secCfg) + return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg) } defer func() { generateConfigFunc = origGen }() @@ -720,11 +720,20 @@ func TestManager_ApplyConfig_IncludesCorazaHandlerWithRuleset(t *testing.T) { if handlerName, ok := handle["handler"].(string); ok && handlerName == "coraza" { // Validate ruleset fields if rsName, ok := handle["ruleset_name"].(string); ok && rsName == "owasp-crs" { + // check for inlined content if rsContent, ok := handle["ruleset_content"].(string); ok && rsContent == "test-rule-content" { if mode, ok := handle["mode"].(string); ok && mode == "block" { found = true } } + // check for written ruleset_path file, if present validate file content + if rsPath, ok := handle["ruleset_path"].(string); ok && rsPath != "" { + // Ensure file exists and contains our content + b, err := os.ReadFile(rsPath) + if err == nil && string(b) == "test-rule-content" { + found = true + } + } } } } @@ -735,6 +744,102 @@ func TestManager_ApplyConfig_IncludesCorazaHandlerWithRuleset(t *testing.T) { assert.True(t, found, "coraza handler with inlined ruleset should be present in generated config") } +func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) { + tmp := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-failwrite") + 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{}, &models.SecurityRuleSet{})) + + // Create host and ruleset + h := models.ProxyHost{DomainNames: "rulesetw.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true} + db.Create(&h) + rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "test-rule-content"} + assert.NoError(t, db.Create(&rs).Error) + sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"} + assert.NoError(t, db.Create(&sec).Error) + + 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 writeFileFunc to return an error for coraza ruleset files only to exercise the warn branch + origWrite := writeFileFunc + writeFileFunc = func(path string, b []byte, perm os.FileMode) error { + if strings.Contains(path, string(filepath.Separator)+"coraza"+string(filepath.Separator)+"rulesets") { + return fmt.Errorf("cannot write") + } + return origWrite(path, b, perm) + } + defer func() { writeFileFunc = origWrite }() + + // Capture rulesetPaths from GenerateConfig + var capturedPaths map[string]string + origGen := 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, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { + capturedPaths = rulesetPaths + return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg) + } + defer func() { generateConfigFunc = origGen }() + + manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) + assert.NoError(t, manager.ApplyConfig(context.Background())) + // writeFile failed, capturedPaths should not contain our ruleset entry + assert.NotContains(t, capturedPaths, "owasp-crs") +} + +func TestManager_ApplyConfig_RulesetDirMkdirFailure(t *testing.T) { + tmp := t.TempDir() + // Create a file at tmp/coraza to cause MkdirAll on tmp/coraza/rulesets to fail + corazaFile := filepath.Join(tmp, "coraza") + os.WriteFile(corazaFile, []byte("not a dir"), 0644) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-mkdirfail") + 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{}, &models.SecurityRuleSet{})) + + // Create host and ruleset + h := models.ProxyHost{DomainNames: "rulesetm.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true} + db.Create(&h) + rs := models.SecurityRuleSet{Name: "owasp-crs", Content: "test-rule-content"} + assert.NoError(t, db.Create(&rs).Error) + sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "owasp-crs"} + assert.NoError(t, db.Create(&sec).Error) + + 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) + // Use tmp as configDir and we already have a file at tmp/coraza which should make MkdirAll to create rulesets fail + manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) + // This should not error (failures to create coraza dir are warned only) + assert.NoError(t, manager.ApplyConfig(context.Background())) +} + func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) { // Capture /load payloads loadCh := make(chan []byte, 10) diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index 2fc2416b..9805d446 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, "", nil, nil, nil) + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) err := Validate(config) require.NoError(t, err) } diff --git a/docs/security.md b/docs/security.md index 5a9f1425..2bc8ca10 100644 --- a/docs/security.md +++ b/docs/security.md @@ -155,6 +155,16 @@ Charon follows a multi-layered security approach. The recommendation below shows - **CrowdSec**: Best for dynamic, behavior-driven blocking — bots, scanners, credential stuffing, IP reputation. CrowdSec integrates with local or external agents and should be used for most bot and scanner detection/remediation. - **WAF (Coraza)**: Best for payload and application-level attacks (XSS, SQLi, file inclusion). Protects against malicious payloads regardless of source IP. + +### Coraza runtime integration test + +To validate runtime Coraza WAF integration locally using Docker Compose: + +1. Build the local Docker image and start services: `docker build -t charon:local . && docker compose -f docker-compose.local.yml up -d`. +2. Configure a ruleset via the API: POST to `/api/v1/security/rulesets` with a rule that would match an XSS payload. +3. Send a request that triggers the rule (e.g., POST with `" http://localhost/) +if [ "$RESPONSE" = "403" ]; then + echo "Coraza WAF blocked payload as expected (HTTP 403)" +else + echo "Unexpected response code: $RESPONSE (expected 403)" + exit 1 +fi + +echo "Coraza integration test complete. Cleaning up..." +docker compose -f docker-compose.local.yml down +echo "Done"