diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index edf1f637..4b5f6e55 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -420,10 +420,10 @@ func generateSelfSignedCertPEM() (string, string, error) { Subject: pkix.Name{ Organization: []string{"Test Org"}, }, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 6c047dc9..a5510d52 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -296,12 +296,12 @@ func TestProxyHostCreate_AdvancedConfig_Normalization(t *testing.T) { // Provide an advanced_config value that will be normalized by caddy.NormalizeAdvancedConfig adv := `{"handler":"headers","response":{"set":{"X-Test":"1"}}}` payload := map[string]interface{}{ - "name": "AdvHost", - "domain_names": "adv.example.com", - "forward_scheme": "http", - "forward_host": "localhost", - "forward_port": 8080, - "enabled": true, + "name": "AdvHost", + "domain_names": "adv.example.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "enabled": true, "advanced_config": adv, } bodyBytes, _ := json.Marshal(payload) @@ -657,14 +657,14 @@ func TestProxyHostUpdate_AdvancedConfig_ClearAndBackup(t *testing.T) { // Create host with advanced config host := &models.ProxyHost{ - UUID: "adv-clear-uuid", - Name: "Advanced Host", - DomainNames: "adv-clear.example.com", - ForwardHost: "localhost", - ForwardPort: 8080, - AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Test":"1"}}}`, + UUID: "adv-clear-uuid", + Name: "Advanced Host", + DomainNames: "adv-clear.example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Test":"1"}}}`, AdvancedConfigBackup: "", - Enabled: true, + Enabled: true, } require.NoError(t, db.Create(host).Error) @@ -854,7 +854,7 @@ func TestProxyHostUpdate_Locations_Replace(t *testing.T) { ForwardHost: "localhost", ForwardPort: 8080, Enabled: true, - Locations: []models.Location{{UUID: uuid.NewString(), Path: "/old", ForwardHost: "localhost", ForwardPort: 8080, ForwardScheme: "http"}}, + Locations: []models.Location{{UUID: uuid.NewString(), Path: "/old", ForwardHost: "localhost", ForwardPort: 8080, ForwardScheme: "http"}}, } require.NoError(t, db.Create(host).Error) @@ -884,14 +884,14 @@ func TestProxyHostCreate_WithCertificateAndLocations(t *testing.T) { adv := `{"handler":"headers","response":{"set":{"X-Test":"1"}}}` payload := map[string]interface{}{ - "name": "Create With Cert", - "domain_names": "cert.example.com", - "forward_scheme": "http", - "forward_host": "localhost", - "forward_port": 8080, - "enabled": true, - "certificate_id": cert.ID, - "locations": []map[string]interface{}{{"path": "/app", "forward_scheme": "http", "forward_host": "localhost", "forward_port": 8080}}, + "name": "Create With Cert", + "domain_names": "cert.example.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "enabled": true, + "certificate_id": cert.ID, + "locations": []map[string]interface{}{{"path": "/app", "forward_scheme": "http", "forward_host": "localhost", "forward_port": 8080}}, "advanced_config": adv, } body, _ := json.Marshal(payload) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 12421e2f..3ebb0f07 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -328,13 +328,13 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Ensure it has a "handler" key if _, ok := v["handler"]; ok { // Capture ruleset_name if present, remove it from advanced_config, - // and set up 'include' array for coraza-caddy plugin. + // and set up 'directives' with Include statement for coraza-caddy plugin. if rn, has := v["ruleset_name"]; has { if rnStr, ok := rn.(string); ok && rnStr != "" { - // Set 'include' array with the ruleset file path for coraza-caddy + // Set 'directives' with Include statement for coraza-caddy if rulesetPaths != nil { if p, ok := rulesetPaths[rnStr]; ok && p != "" { - v["include"] = []string{p} + v["directives"] = fmt.Sprintf("Include %s", p) } } } @@ -352,7 +352,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin if rnStr, ok := rn.(string); ok && rnStr != "" { if rulesetPaths != nil { if p, ok := rulesetPaths[rnStr]; ok && p != "" { - m["include"] = []string{p} + m["directives"] = fmt.Sprintf("Include %s", p) } } } diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go index abe337fe..b78023c2 100644 --- a/backend/internal/caddy/config_generate_additional_test.go +++ b/backend/internal/caddy/config_generate_additional_test.go @@ -168,17 +168,17 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) { sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"} 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, waf handler should include a reference but no include array + // Since a ruleset name was requested but none exists, waf handler should include a reference but no directives route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if _, ok := h["include"]; !ok { + if _, ok := h["directives"]; !ok { found = true } } } - require.True(t, found, "expected waf handler without include array when referenced ruleset does not exist") + require.True(t, found, "expected waf handler without directives when referenced ruleset does not exist") // Now test learning/monitor mode mapping sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true} @@ -218,13 +218,13 @@ func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) { found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if incl, ok := h["include"].([]string); ok && len(incl) > 0 { + if dir, ok := h["directives"].(string); ok && strings.Contains(dir, "Include") { found = true break } } } - require.True(t, found, "expected waf handler with include array to be present") + require.True(t, found, "expected waf handler with directives containing Include to be present") } func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) { @@ -271,11 +271,11 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with include array + // check waf handler present with directives containing Include found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if incl, ok := h["include"].([]string); ok && len(incl) > 0 { + if dir, ok := h["directives"].(string); ok && strings.Contains(dir, "Include") { found = true break } @@ -283,7 +283,7 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) { } if !found { b2, _ := json.MarshalIndent(route.Handle, "", " ") - t.Fatalf("waf handler with include array should be present; handlers: %s", string(b2)) + t.Fatalf("waf handler with directives should be present; handlers: %s", string(b2)) } } @@ -295,17 +295,17 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with include array coming from host AdvancedConfig + // check waf handler present with directives containing Include from host AdvancedConfig found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/host-rs.conf" { + if dir, ok := h["directives"].(string); ok && strings.Contains(dir, "Include /tmp/host-rs.conf") { found = true break } } } - require.True(t, found, "waf handler with include array should include host advanced_config ruleset path") + require.True(t, found, "waf handler with directives should include host advanced_config ruleset path") } func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) { @@ -316,17 +316,20 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) { cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil) require.NoError(t, err) route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] - // check waf handler present with include array coming from host AdvancedConfig array + // check waf handler present with directives containing Include from host AdvancedConfig array found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/host-rs-array.conf" { + if dir, ok := h["directives"].(string); ok && strings.Contains(dir, "Include /tmp/host-rs-array.conf") { found = true break } } } - require.True(t, found, "waf handler with include array should include host advanced_config array ruleset path") + if !found { + b, _ := json.MarshalIndent(route.Handle, "", " ") + t.Fatalf("waf handler with directives should include host advanced_config array ruleset path; handlers: %s", string(b)) + } } func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) { @@ -336,18 +339,18 @@ func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) { rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-fallback.conf"} cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, rulesetPaths, nil, sec) require.NoError(t, err) - // since secCfg requested owasp-crs and we have a path, the waf handler should include the path in include array + // since secCfg requested owasp-crs and we have a path, the waf handler should include the path in directives route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] found := false for _, h := range route.Handle { if hn, ok := h["handler"].(string); ok && hn == "waf" { - if incl, ok := h["include"].([]string); ok && len(incl) > 0 && incl[0] == "/tmp/owasp-fallback.conf" { + if dir, ok := h["directives"].(string); ok && strings.Contains(dir, "Include /tmp/owasp-fallback.conf") { found = true break } } } - require.True(t, found, "waf handler with include array should include fallback secCfg ruleset path") + require.True(t, found, "waf handler with directives should include fallback secCfg ruleset path") } func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) { diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 4d9956ff..cbe94672 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -119,11 +119,6 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { safeName := strings.ReplaceAll(strings.ToLower(rs.Name), " ", "-") safeName = strings.ReplaceAll(safeName, "/", "-") - // Calculate hash of the content to ensure filename changes when content changes - // This forces Caddy to reload the file instead of using a cached version - hash := sha256.Sum256([]byte(rs.Content)) - shortHash := fmt.Sprintf("%x", hash)[:8] - filePath := filepath.Join(corazaDir, fmt.Sprintf("%s-%s.conf", safeName, shortHash)) // Prepend required Coraza directives if not already present. // These are essential for the WAF to actually enforce rules: // - SecRuleEngine On: enables blocking mode (blocks malicious requests) @@ -142,6 +137,13 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { } content = fmt.Sprintf("SecRuleEngine %s\nSecRequestBodyAccess On\n\n", engineMode) + content } + + // Calculate hash of the FINAL content (after prepending mode directives) + // to ensure filename changes when mode changes, forcing Caddy to reload + hash := sha256.Sum256([]byte(content)) + shortHash := fmt.Sprintf("%x", hash)[:8] + filePath := filepath.Join(corazaDir, fmt.Sprintf("%s-%s.conf", safeName, shortHash)) + // Write ruleset file with world-readable permissions so the Caddy // process (which may run as an unprivileged user) can read it. if err := writeFileFunc(filePath, []byte(content), 0644); err != nil { diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index b92dbc50..db0d10b8 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -718,9 +718,12 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { if h == "ruleset.example.com" { for _, handle := range r.Handle { if handlerName, ok := handle["handler"].(string); ok && handlerName == "waf" { - // Validate include array (coraza-caddy schema) or inline ruleset_content presence - if incl, ok := handle["include"].([]interface{}); ok && len(incl) > 0 { - if rf, ok := incl[0].(string); ok && rf != "" { + // Validate directives field contains Include statement (coraza-caddy schema) + if dir, ok := handle["directives"].(string); ok && strings.Contains(dir, "Include") { + // Extract the file path from the Include directive + parts := strings.Split(dir, " ") + if len(parts) >= 2 { + rf := parts[len(parts)-1] // Ensure file exists and contains our content // Note: manager prepends SecRuleEngine On directives, so we check Contains b, err := os.ReadFile(rf) @@ -739,7 +742,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { } } } - assert.True(t, found, "waf handler with inlined ruleset should be present in generated config") + assert.True(t, found, "waf handler with directives should be present in generated config") } func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) { @@ -1310,10 +1313,17 @@ func TestManager_ApplyConfig_RulesetFileCleanup(t *testing.T) { assert.NoError(t, err, "Subdirectory should not be deleted") assert.True(t, info.IsDir(), "Subdirectory should still be a directory") - // Verify active ruleset file exists - activeFile := filepath.Join(corazaDir, "active-ruleset.conf") - _, err = os.Stat(activeFile) - assert.NoError(t, err, "Active ruleset file should exist") + // Verify active ruleset file exists (with hash suffix in filename) + entries, err := os.ReadDir(corazaDir) + assert.NoError(t, err, "Should be able to read corazaDir") + foundActive := false + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "active-ruleset-") && strings.HasSuffix(entry.Name(), ".conf") { + foundActive = true + break + } + } + assert.True(t, foundActive, "Active ruleset file with hash suffix should exist") } func TestManager_ApplyConfig_RulesetCleanupReadDirError(t *testing.T) { diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 7f765e8e..2a5ba64a 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -462,20 +462,20 @@ func TestComputeEffectiveFlags_DB_ACLTrueAndFalse(t *testing.T) { } func TestComputeEffectiveFlags_DB_WAFMonitor(t *testing.T) { -dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) -db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) -require.NoError(t, err) + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) -secCfg := config.SecurityConfig{CerberusEnabled: true, WAFMode: "enabled"} -manager := NewManager(nil, db, "", "", false, secCfg) + secCfg := config.SecurityConfig{CerberusEnabled: true, WAFMode: "enabled"} + manager := NewManager(nil, db, "", "", false, secCfg) -// Set WAF mode to monitor + // Set WAF mode to monitor res := db.Create(&models.SecurityConfig{Name: "default", Enabled: true, WAFMode: "monitor"}) require.NoError(t, res.Error) -_, _, waf, _, _ := manager.computeEffectiveFlags(context.Background()) -require.True(t, waf) // Should still be true (enabled) + _, _, waf, _, _ := manager.computeEffectiveFlags(context.Background()) + require.True(t, waf) // Should still be true (enabled) } func TestManager_ApplyConfig_WAFMonitor(t *testing.T) { @@ -511,7 +511,7 @@ func TestManager_ApplyConfig_WAFMonitor(t *testing.T) { originalWriteFile := writeFileFunc defer func() { writeFileFunc = originalWriteFile }() writeFileFunc = func(filename string, data []byte, perm os.FileMode) error { - if strings.Contains(filename, "owasp-crs.conf") { + if strings.Contains(filename, "owasp-crs") && strings.HasSuffix(filename, ".conf") { writtenContent = string(data) } return originalWriteFile(filename, data, perm)