feat: Implement comprehensive tests and fixes for Coraza WAF integration

- Add unit tests for WAF ruleset selection priority and handler validation in config_waf_test.go.
- Enhance manager.go to sanitize ruleset names, preventing path traversal vulnerabilities.
- Introduce debug logging for WAF configuration state in manager.go to aid troubleshooting.
- Create integration tests to verify WAF handler presence and ruleset sanitization in manager_additional_test.go.
- Update coraza_integration.sh to include verification steps for WAF configuration and improved error handling.
- Document the Coraza WAF integration fix plan, detailing root cause analysis and implementation tasks.
This commit is contained in:
GitHub Actions
2025-12-04 04:04:37 +00:00
parent 7095057c48
commit 2adf094f1c
10 changed files with 1281 additions and 34 deletions

View File

@@ -45,6 +45,35 @@ jobs:
scripts/coraza_integration.sh 2>&1 | tee waf-test-output.txt
exit ${PIPESTATUS[0]}
- name: Dump Debug Info on Failure
if: failure()
run: |
echo "## 🔍 Debug Information" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Container Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker ps -a --filter "name=charon" --filter "name=coraza" >> $GITHUB_STEP_SUMMARY 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Caddy Admin Config" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:2019/config 2>/dev/null | head -200 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve Caddy config" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Charon Container Logs (last 100 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs charon-debug 2>&1 | tail -100 >> $GITHUB_STEP_SUMMARY || echo "No container logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### WAF Ruleset Files" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker exec charon-debug sh -c 'ls -la /app/data/caddy/coraza/rulesets/ 2>/dev/null && echo "---" && cat /app/data/caddy/coraza/rulesets/*.conf 2>/dev/null' >> $GITHUB_STEP_SUMMARY || echo "No ruleset files found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: WAF Integration Summary
if: always()
run: |

View File

@@ -721,10 +721,19 @@ func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig,
return h, nil
}
// 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.
// buildWAFHandler returns a WAF handler (Coraza) configuration.
// The coraza-caddy plugin registers as http.handlers.waf and expects:
// - handler: "waf"
// - directives: ModSecurity directive string including Include statements
func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) {
// Early exit if WAF is disabled
if !wafEnabled {
return nil, nil
}
if secCfg != nil && secCfg.WAFMode == "disabled" {
return nil, nil
}
// If the host provided an advanced_config containing a 'ruleset_name', prefer that value
var hostRulesetName string
if host != nil && host.AdvancedConfig != "" {
@@ -738,23 +747,56 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet,
}
}
// Find a ruleset to associate with WAF; prefer name match by host.Application, host.AdvancedConfig ruleset_name or default 'owasp-crs'
// Find a ruleset to associate with WAF
// Priority order:
// 1. Exact match to secCfg.WAFRulesSource (user's global choice)
// 2. Exact match to hostRulesetName (per-host advanced_config)
// 3. Match to host.Application (app-specific defaults)
// 4. Fallback to owasp-crs
var selected *models.SecurityRuleSet
var hostRulesetMatch, appMatch, owaspFallback *models.SecurityRuleSet
// First pass: find all potential matches
for i, r := range rulesets {
if r.Name == "owasp-crs" || (host != nil && r.Name == host.Application) || (hostRulesetName != "" && r.Name == hostRulesetName) || (secCfg != nil && r.Name == secCfg.WAFRulesSource) {
// Priority 1: Global WAF rules source - highest priority, select immediately
if secCfg != nil && secCfg.WAFRulesSource != "" && r.Name == secCfg.WAFRulesSource {
selected = &rulesets[i]
break
}
// Priority 2: Per-host ruleset name from advanced_config
if hostRulesetName != "" && r.Name == hostRulesetName && hostRulesetMatch == nil {
hostRulesetMatch = &rulesets[i]
}
// Priority 3: Match by host application
if host != nil && r.Name == host.Application && appMatch == nil {
appMatch = &rulesets[i]
}
// Priority 4: Track owasp-crs as fallback
if r.Name == "owasp-crs" && owaspFallback == nil {
owaspFallback = &rulesets[i]
}
}
if !wafEnabled {
return nil, nil
// Second pass: select by priority if not already selected
if selected == nil {
if hostRulesetMatch != nil {
selected = hostRulesetMatch
} else if appMatch != nil {
selected = appMatch
} else if owaspFallback != nil {
selected = owaspFallback
}
}
// Build the handler with directives
h := Handler{"handler": "waf"}
directivesSet := false
if selected != nil {
if rulesetPaths != nil {
if p, ok := rulesetPaths[selected.Name]; ok && p != "" {
h["directives"] = fmt.Sprintf("Include %s", p)
directivesSet = true
}
}
} else if secCfg != nil && secCfg.WAFRulesSource != "" {
@@ -762,14 +804,16 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet,
if rulesetPaths != nil {
if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" {
h["directives"] = fmt.Sprintf("Include %s", p)
directivesSet = true
}
}
}
// WAF enablement is handled by the caller. Don't add a 'mode' field
// here because the module expects a specific configuration schema.
if secCfg != nil && secCfg.WAFMode == "disabled" {
// Bug fix: Don't return a WAF handler without directives - it creates a no-op WAF
if !directivesSet {
return nil, nil
}
return h, nil
}

View File

@@ -222,8 +222,11 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
acl := models.AccessList{ID: 200, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline1", DomainNames: "pipe.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true}
// Provide rulesets and paths so WAF handler is created with directives
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
secCfg := &models.SecurityConfig{CrowdSecMode: "local"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, nil, secCfg)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, secCfg)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]

View File

@@ -50,8 +50,11 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
acl := models.AccessList{ID: 201, Name: "WL2", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline2", DomainNames: "pipe-loc.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true, Locations: []models.Location{{Path: "/loc", ForwardHost: "app", ForwardPort: 9000}}}
// Provide rulesets and paths so WAF handler is created with directives
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
sec := &models.SecurityConfig{CrowdSecMode: "local"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", nil, nil, nil, sec)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, sec)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
@@ -168,21 +171,20 @@ 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 directives
// Since a ruleset name was requested but none exists, NO waf handler should be created
// (Bug fix: don't create a no-op WAF handler without 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["directives"]; !ok {
found = true
}
t.Fatalf("expected NO waf handler when referenced ruleset does not exist, but found: %v", h)
}
}
require.True(t, found, "expected waf handler without directives when referenced ruleset does not exist")
// Now test learning/monitor mode mapping
// Now test with valid ruleset - WAF handler should be created
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true}
cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec2)
cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, sec2)
require.NoError(t, err)
route2 := cfg2.Apps.HTTP.Servers["charon_server"].Routes[0]
monitorFound := false
@@ -191,7 +193,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
monitorFound = true
}
}
require.True(t, monitorFound, "expected waf handler when WAFLearning is true")
require.True(t, monitorFound, "expected waf handler when WAFLearning is true and ruleset exists")
}
func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) {

View File

@@ -0,0 +1,283 @@
package caddy
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/models"
)
// TestBuildWAFHandler_PathTraversalAttack tests path traversal attempts in ruleset names
func TestBuildWAFHandler_PathTraversalAttack(t *testing.T) {
tests := []struct {
name string
rulesetName string
shouldMatch bool // Whether the ruleset should be found
description string
}{
{
name: "Path traversal in ruleset name",
rulesetName: "../../../etc/passwd",
shouldMatch: false,
description: "Ruleset with path traversal should not match any legitimate path",
},
{
name: "Null byte injection",
rulesetName: "rules\x00.conf",
shouldMatch: false,
description: "Ruleset with null bytes should not match",
},
{
name: "URL encoded traversal",
rulesetName: "..%2F..%2Fetc%2Fpasswd",
shouldMatch: false,
description: "URL encoded path traversal should not match",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: tc.rulesetName}}
// Only provide paths for legitimate rulesets
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: tc.rulesetName}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
if tc.shouldMatch {
require.NotNil(t, handler)
} else {
// Handler should be nil since no matching path exists
require.Nil(t, handler, tc.description)
}
})
}
}
// TestBuildWAFHandler_SQLInjectionInRulesetName tests SQL injection patterns in ruleset names
func TestBuildWAFHandler_SQLInjectionInRulesetName(t *testing.T) {
sqlInjectionPatterns := []string{
"'; DROP TABLE rulesets; --",
"1' OR '1'='1",
"UNION SELECT * FROM users--",
"admin'/*",
}
for _, pattern := range sqlInjectionPatterns {
t.Run(pattern, func(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
// Create ruleset with malicious name but only provide path for safe ruleset
rulesets := []models.SecurityRuleSet{{Name: pattern}, {Name: "owasp-crs"}}
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: pattern}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
// Should return nil since the malicious name has no corresponding path
require.Nil(t, handler, "SQL injection pattern should not produce valid handler")
})
}
}
// TestBuildWAFHandler_XSSInAdvancedConfig tests XSS patterns in advanced_config JSON
func TestBuildWAFHandler_XSSInAdvancedConfig(t *testing.T) {
xssPatterns := []string{
`{"ruleset_name":"<script>alert(1)</script>"}`,
`{"ruleset_name":"<img src=x onerror=alert(1)>"}`,
`{"ruleset_name":"javascript:alert(1)"}`,
`{"ruleset_name":"<svg/onload=alert(1)>"}`,
}
for _, pattern := range xssPatterns {
t.Run(pattern, func(t *testing.T) {
host := &models.ProxyHost{
UUID: "test-host",
AdvancedConfig: pattern,
}
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block"}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
// Should fall back to owasp-crs since XSS pattern won't match any ruleset
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, "owasp-crs")
// Ensure XSS content is NOT in the output
require.NotContains(t, directives, "<script>")
require.NotContains(t, directives, "javascript:")
})
}
}
// TestBuildWAFHandler_HugePayload tests handling of very large inputs
func TestBuildWAFHandler_HugePayload(t *testing.T) {
// Create a very large ruleset name (1MB)
hugeName := strings.Repeat("A", 1024*1024)
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: hugeName}, {Name: "owasp-crs"}}
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block"}
// Should not panic or crash
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
// Falls back to owasp-crs since huge name has no path
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, "owasp-crs")
}
// TestBuildWAFHandler_EmptyAndWhitespaceInputs tests boundary conditions
func TestBuildWAFHandler_EmptyAndWhitespaceInputs(t *testing.T) {
tests := []struct {
name string
rulesetName string
wafRulesSource string
expectNil bool
}{
{
name: "Empty string WAFRulesSource",
rulesetName: "owasp-crs",
wafRulesSource: "",
expectNil: false, // Falls back to owasp-crs
},
{
name: "Whitespace-only WAFRulesSource",
rulesetName: "owasp-crs",
wafRulesSource: " ",
expectNil: false, // Falls back to owasp-crs (whitespace doesn't match, but fallback exists)
},
{
name: "Tab and newline in WAFRulesSource",
rulesetName: "owasp-crs",
wafRulesSource: "\t\n",
expectNil: false, // Falls back to owasp-crs (special chars don't match, but fallback exists)
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: tc.rulesetName}}
rulesetPaths := map[string]string{
tc.rulesetName: "/app/data/caddy/coraza/rulesets/" + tc.rulesetName + ".conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: tc.wafRulesSource}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
if tc.expectNil {
require.Nil(t, handler)
} else {
require.NotNil(t, handler)
}
})
}
}
// TestBuildWAFHandler_ConcurrentRulesetSelection tests that selection is deterministic
func TestBuildWAFHandler_ConcurrentRulesetSelection(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{
{Name: "ruleset-a"},
{Name: "ruleset-b"},
{Name: "ruleset-c"},
{Name: "owasp-crs"},
}
rulesetPaths := map[string]string{
"ruleset-a": "/path/ruleset-a.conf",
"ruleset-b": "/path/ruleset-b.conf",
"ruleset-c": "/path/ruleset-c.conf",
"owasp-crs": "/path/owasp.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "ruleset-b"}
// Run 100 times to verify determinism
for i := 0; i < 100; i++ {
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, "ruleset-b", "Selection should always pick WAFRulesSource")
}
}
// TestBuildWAFHandler_NilSecCfg tests handling when secCfg is nil
func TestBuildWAFHandler_NilSecCfg(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
// nil secCfg should not panic, should fall back to owasp-crs
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, nil, true)
require.NoError(t, err)
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, "owasp-crs")
}
// TestBuildWAFHandler_NilHost tests handling when host is nil
func TestBuildWAFHandler_NilHost(t *testing.T) {
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{
"owasp-crs": "/app/data/caddy/coraza/rulesets/owasp-crs.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block"}
// nil host should not panic
handler, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, "owasp-crs")
}
// TestBuildWAFHandler_SpecialCharactersInRulesetName tests handling of special chars
func TestBuildWAFHandler_SpecialCharactersInRulesetName(t *testing.T) {
specialNames := []struct {
name string
safeName string
}{
{"ruleset with spaces", "ruleset-with-spaces"},
{"ruleset/with/slashes", "ruleset-with-slashes"},
{"UPPERCASE-RULESET", "uppercase-ruleset"},
{"ruleset_with_underscores", "ruleset_with_underscores"},
{"ruleset.with.dots", "ruleset.with.dots"},
}
for _, tc := range specialNames {
t.Run(tc.name, func(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: tc.name}}
// Simulate path that would be generated by manager.go
rulesetPaths := map[string]string{
tc.name: "/app/data/caddy/coraza/rulesets/" + tc.safeName + "-abc123.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: tc.name}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, tc.safeName)
})
}
}

View File

@@ -0,0 +1,292 @@
package caddy
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/models"
)
// TestBuildWAFHandler_RulesetSelectionPriority verifies the priority order:
// 1. secCfg.WAFRulesSource (user's global choice)
// 2. hostRulesetName from advanced_config
// 3. host.Application
// 4. owasp-crs fallback
func TestBuildWAFHandler_RulesetSelectionPriority(t *testing.T) {
tests := []struct {
name string
host *models.ProxyHost
rulesets []models.SecurityRuleSet
rulesetPaths map[string]string
secCfg *models.SecurityConfig
wafEnabled bool
expectedInclude string // Expected substring in directives, empty if handler should be nil
}{
{
name: "WAFRulesSource takes priority over owasp-crs",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}, {Name: "custom-xss"}},
rulesetPaths: map[string]string{
"owasp-crs": "/app/data/rulesets/owasp-crs.conf",
"custom-xss": "/app/data/rulesets/custom-xss.conf",
},
secCfg: &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "custom-xss"},
wafEnabled: true,
expectedInclude: "custom-xss.conf",
},
{
name: "hostRulesetName takes priority over owasp-crs",
host: &models.ProxyHost{
UUID: "test-host",
AdvancedConfig: `{"ruleset_name":"per-host-rules"}`,
},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}, {Name: "per-host-rules"}},
rulesetPaths: map[string]string{
"owasp-crs": "/app/data/rulesets/owasp-crs.conf",
"per-host-rules": "/app/data/rulesets/per-host-rules.conf",
},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
expectedInclude: "per-host-rules.conf",
},
{
name: "host.Application takes priority over owasp-crs",
host: &models.ProxyHost{
UUID: "test-host",
Application: "wordpress",
},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}, {Name: "wordpress"}},
rulesetPaths: map[string]string{
"owasp-crs": "/app/data/rulesets/owasp-crs.conf",
"wordpress": "/app/data/rulesets/wordpress.conf",
},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
expectedInclude: "wordpress.conf",
},
{
name: "owasp-crs used as fallback when no other match",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}, {Name: "unrelated-rules"}},
rulesetPaths: map[string]string{
"owasp-crs": "/app/data/rulesets/owasp-crs.conf",
"unrelated-rules": "/app/data/rulesets/unrelated.conf",
},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
expectedInclude: "owasp-crs.conf",
},
{
name: "WAFRulesSource takes priority over host.Application and owasp-crs",
host: &models.ProxyHost{
UUID: "test-host",
Application: "wordpress",
},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}, {Name: "wordpress"}, {Name: "global-custom"}},
rulesetPaths: map[string]string{
"owasp-crs": "/app/data/rulesets/owasp-crs.conf",
"wordpress": "/app/data/rulesets/wordpress.conf",
"global-custom": "/app/data/rulesets/global-custom.conf",
},
secCfg: &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "global-custom"},
wafEnabled: true,
expectedInclude: "global-custom.conf",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
handler, err := buildWAFHandler(tc.host, tc.rulesets, tc.rulesetPaths, tc.secCfg, tc.wafEnabled)
require.NoError(t, err)
if tc.expectedInclude == "" {
require.Nil(t, handler)
return
}
require.NotNil(t, handler)
directives, ok := handler["directives"].(string)
require.True(t, ok, "directives should be a string")
require.Contains(t, directives, tc.expectedInclude)
})
}
}
// TestBuildWAFHandler_NoDirectivesReturnsNil verifies that the handler returns nil
// when no directives can be set (Bug fix #2 from the plan)
func TestBuildWAFHandler_NoDirectivesReturnsNil(t *testing.T) {
tests := []struct {
name string
host *models.ProxyHost
rulesets []models.SecurityRuleSet
rulesetPaths map[string]string
secCfg *models.SecurityConfig
wafEnabled bool
}{
{
name: "Empty rulesets returns nil",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{},
rulesetPaths: map[string]string{},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
},
{
name: "Ruleset exists but no path mapping returns nil",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{{Name: "my-rules"}},
rulesetPaths: map[string]string{
"other-rules": "/path/to/other.conf", // Path for different ruleset
},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
},
{
name: "WAFRulesSource specified but not in rulesets or paths returns nil",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{{Name: "other-rules"}},
rulesetPaths: map[string]string{},
secCfg: &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent"},
wafEnabled: true,
},
{
name: "Empty path in rulesetPaths returns nil",
host: &models.ProxyHost{UUID: "test-host"},
rulesets: []models.SecurityRuleSet{{Name: "owasp-crs"}},
rulesetPaths: map[string]string{
"owasp-crs": "", // Empty path
},
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
handler, err := buildWAFHandler(tc.host, tc.rulesets, tc.rulesetPaths, tc.secCfg, tc.wafEnabled)
require.NoError(t, err)
require.Nil(t, handler, "Handler should be nil when no directives can be set")
})
}
}
// TestBuildWAFHandler_DisabledModes verifies WAF is disabled correctly
func TestBuildWAFHandler_DisabledModes(t *testing.T) {
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{"owasp-crs": "/path/to/rules.conf"}
host := &models.ProxyHost{UUID: "test-host"}
tests := []struct {
name string
secCfg *models.SecurityConfig
wafEnabled bool
}{
{
name: "wafEnabled false returns nil",
secCfg: &models.SecurityConfig{WAFMode: "block"},
wafEnabled: false,
},
{
name: "WAFMode disabled returns nil",
secCfg: &models.SecurityConfig{WAFMode: "disabled"},
wafEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, tc.secCfg, tc.wafEnabled)
require.NoError(t, err)
require.Nil(t, handler)
})
}
}
// TestBuildWAFHandler_HandlerStructure verifies the JSON structure matches the Handoff Contract
func TestBuildWAFHandler_HandlerStructure(t *testing.T) {
host := &models.ProxyHost{UUID: "test-host"}
rulesets := []models.SecurityRuleSet{{Name: "integration-xss"}}
rulesetPaths := map[string]string{
"integration-xss": "/app/data/caddy/coraza/rulesets/integration-xss-a1b2c3d4.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "integration-xss"}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
require.NotNil(t, handler)
// Verify handler type
require.Equal(t, "waf", handler["handler"])
// Verify directives contain Include statement
directives, ok := handler["directives"].(string)
require.True(t, ok)
require.Contains(t, directives, "Include /app/data/caddy/coraza/rulesets/integration-xss-a1b2c3d4.conf")
// Verify JSON marshaling produces expected structure
jsonBytes, err := json.Marshal(handler)
require.NoError(t, err)
require.Contains(t, string(jsonBytes), `"handler":"waf"`)
require.Contains(t, string(jsonBytes), `"directives":"Include`)
}
// TestBuildWAFHandler_AdvancedConfigParsing verifies advanced_config JSON parsing
func TestBuildWAFHandler_AdvancedConfigParsing(t *testing.T) {
rulesets := []models.SecurityRuleSet{
{Name: "owasp-crs"},
{Name: "custom-ruleset"},
}
rulesetPaths := map[string]string{
"owasp-crs": "/path/owasp.conf",
"custom-ruleset": "/path/custom.conf",
}
secCfg := &models.SecurityConfig{WAFMode: "block"}
tests := []struct {
name string
advancedConfig string
expectedInclude string
}{
{
name: "Valid ruleset_name in advanced_config",
advancedConfig: `{"ruleset_name":"custom-ruleset"}`,
expectedInclude: "custom.conf",
},
{
name: "Invalid JSON falls back to owasp-crs",
advancedConfig: `{invalid json`,
expectedInclude: "owasp.conf",
},
{
name: "Empty advanced_config falls back to owasp-crs",
advancedConfig: "",
expectedInclude: "owasp.conf",
},
{
name: "Empty ruleset_name string falls back to owasp-crs",
advancedConfig: `{"ruleset_name":""}`,
expectedInclude: "owasp.conf",
},
{
name: "Non-string ruleset_name falls back to owasp-crs",
advancedConfig: `{"ruleset_name":123}`,
expectedInclude: "owasp.conf",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host := &models.ProxyHost{
UUID: "test-host",
AdvancedConfig: tc.advancedConfig,
}
handler, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
require.NoError(t, err)
require.NotNil(t, handler)
directives := handler["directives"].(string)
require.Contains(t, directives, tc.expectedInclude)
})
}
}

View File

@@ -115,9 +115,19 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
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), " ", "-")
// Sanitize name to a safe filename - prevent path traversal and special chars
safeName := strings.ToLower(rs.Name)
safeName = strings.ReplaceAll(safeName, " ", "-")
safeName = strings.ReplaceAll(safeName, "/", "-")
safeName = strings.ReplaceAll(safeName, "\\", "-")
safeName = strings.ReplaceAll(safeName, "..", "") // Strip path traversal sequences
safeName = strings.ReplaceAll(safeName, "\x00", "") // Strip null bytes
safeName = strings.ReplaceAll(safeName, "%2f", "-") // URL-encoded slash
safeName = strings.ReplaceAll(safeName, "%2e", "") // URL-encoded dot
safeName = strings.Trim(safeName, ".-") // Trim leading/trailing dots and dashes
if safeName == "" {
safeName = "unnamed-ruleset"
}
// Prepend required Coraza directives if not already present.
// These are essential for the WAF to actually enforce rules:
@@ -189,6 +199,21 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
return fmt.Errorf("generate config: %w", err)
}
// Debug logging: WAF configuration state for troubleshooting integration issues
logger.Log().WithFields(map[string]interface{}{
"waf_enabled": wafEnabled,
"waf_mode": secCfg.WAFMode,
"waf_rules_source": secCfg.WAFRulesSource,
"ruleset_count": len(rulesets),
"ruleset_paths_len": len(rulesetPaths),
}).Debug("WAF configuration state")
for rsName, rsPath := range rulesetPaths {
logger.Log().WithFields(map[string]interface{}{
"ruleset_name": rsName,
"ruleset_path": rsPath,
}).Debug("WAF ruleset path mapping")
}
// Log generated config size and a compact JSON snippet for debugging when in debug mode
if cfgJSON, jerr := jsonMarshalDebugFunc(config); jerr == nil {
logger.Log().WithField("config_json_len", len(cfgJSON)).Debug("generated Caddy config JSON")

View File

@@ -1471,3 +1471,71 @@ func TestManager_ApplyConfig_WAFModeBlockExplicit(t *testing.T) {
assert.True(t, strings.Contains(content, "SecRuleEngine On"), "SecRuleEngine On should be prepended in block mode")
assert.False(t, strings.Contains(content, "DetectionOnly"), "DetectionOnly should NOT be present in block mode")
}
// TestManager_ApplyConfig_RulesetNamePathTraversal tests that path traversal attempts
// in ruleset names are sanitized and do not escape the rulesets directory
func TestManager_ApplyConfig_RulesetNamePathTraversal(t *testing.T) {
tmp := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-pathtraversal")
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
h := models.ProxyHost{DomainNames: "traversal.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
db.Create(&h)
// Create ruleset with path traversal attempt in name
rs := models.SecurityRuleSet{Name: "../../../etc/passwd", Content: "SecRule REQUEST_BODY \"<script>\" \"id:99999,phase:2,deny\""}
assert.NoError(t, db.Create(&rs).Error)
sec := models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.1/32", WAFMode: "block", WAFRulesSource: "../../../etc/passwd"}
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)
// Track where files are written
var writtenPath string
origWrite := writeFileFunc
writeFileFunc = func(path string, b []byte, perm os.FileMode) error {
if strings.Contains(path, "coraza") {
writtenPath = path
}
return origWrite(path, b, perm)
}
defer func() { writeFileFunc = origWrite }()
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
assert.NoError(t, manager.ApplyConfig(context.Background()))
// Verify the file was written inside the expected coraza/rulesets directory
expectedDir := filepath.Join(tmp, "coraza", "rulesets")
assert.True(t, strings.HasPrefix(writtenPath, expectedDir), "Ruleset file should be inside coraza/rulesets directory")
// Verify the sanitized filename does not contain path traversal sequences
filename := filepath.Base(writtenPath)
assert.NotContains(t, filename, "..", "Path traversal sequence should be stripped")
assert.NotContains(t, filename, "/", "Forward slash should be stripped")
assert.NotContains(t, filename, "\\", "Backslash should be stripped")
// The filename should be sanitized and end with .conf
assert.True(t, strings.HasSuffix(filename, ".conf"), "Ruleset file should have .conf extension")
// Verify the directory is strictly inside the expected location
dir := filepath.Dir(writtenPath)
assert.Equal(t, expectedDir, dir, "Ruleset must be written only to the coraza/rulesets directory")
}

View File

@@ -0,0 +1,405 @@
# 📋 Plan: Coraza WAF Integration Fix
> **Status**: Ready for Implementation
> **Created**: 2024-12-04
> **CI Failure Reference**: [Run #19912145599](https://github.com/Wikid82/Charon/actions/runs/19912145599)
---
## 🧐 UX & Context Analysis
### Current State
The Coraza WAF integration is **architecturally correct** - the plugin is properly compiled into Caddy via xcaddy, and the handler generation pipeline exists. However, the CI integration test consistently fails because the generated Caddy configuration has bugs that prevent the WAF from properly evaluating requests.
### Desired User Flow
1. User creates a WAF ruleset via Security → WAF Config page
2. User enables WAF mode (`block` or `monitor`) in Security settings
3. WAF automatically applies to all proxy hosts
4. Malicious requests are blocked (block mode) or logged (monitor mode)
5. User sees WAF activity in logs and metrics
### Integration Test Expectation
```
POST http://integration.local/post
Body: <script>alert(1)</script>
Expected: HTTP 403 (blocked by Coraza)
Actual: HTTP 200 (request passed through)
```
---
## 🤝 Handoff Contract (The Truth)
### Caddy JSON API - WAF Handler Format
The `coraza-caddy` plugin registers as `http.handlers.waf`. The JSON structure must be:
```json
{
"handler": "waf",
"directives": "SecRuleEngine On\nSecRequestBodyAccess On\nInclude /app/data/caddy/coraza/rulesets/integration-xss-a1b2c3d4.conf"
}
```
**Key Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `handler` | string | ✅ | Must be `"waf"` (maps to `http.handlers.waf`) |
| `directives` | string | ✅ | ModSecurity directive string including `Include` statements |
| `load_owasp_crs` | bool | ❌ | If true, loads embedded OWASP CRS (not used in our integration) |
### Ruleset File Format
Files in `/app/data/caddy/coraza/rulesets/{name}-{hash}.conf`:
```modsecurity
SecRuleEngine On
SecRequestBodyAccess On
SecRule REQUEST_BODY "<script>" "id:12345,phase:2,deny,status:403,msg:'XSS blocked'"
```
**Critical Directives:**
- `SecRuleEngine On` → Blocking mode (returns 403)
- `SecRuleEngine DetectionOnly` → Monitor mode (logs but passes through)
- `SecRequestBodyAccess On` → Required to inspect POST bodies
---
## 🔍 Root Cause Analysis
### Bug 1: Ruleset Selection Priority (CRITICAL)
**File**: [backend/internal/caddy/config.go#L743-L748](../backend/internal/caddy/config.go#L743-L748)
```go
// CURRENT (buggy):
if r.Name == "owasp-crs" || (host != nil && r.Name == host.Application) ||
(hostRulesetName != "" && r.Name == hostRulesetName) ||
(secCfg != nil && r.Name == secCfg.WAFRulesSource) {
```
**Problem**: `owasp-crs` is checked FIRST, so if any ruleset named "owasp-crs" exists in the database, it will always be selected even when the user specifies a different ruleset via `waf_rules_source`.
**Fix**: Reorder conditions to prioritize user-specified names:
```go
// FIXED:
if (secCfg != nil && secCfg.WAFRulesSource != "" && r.Name == secCfg.WAFRulesSource) ||
(hostRulesetName != "" && r.Name == hostRulesetName) ||
(host != nil && r.Name == host.Application) ||
r.Name == "owasp-crs" {
```
### Bug 2: WAF Handler Returned Without Directives
**File**: [backend/internal/caddy/config.go#L754-L770](../backend/internal/caddy/config.go#L754-L770)
```go
// CURRENT (buggy):
h := Handler{"handler": "waf"}
if selected != nil {
// set directives...
} else if secCfg != nil && secCfg.WAFRulesSource != "" {
// set directives...
}
// BUG: Returns handler even if no directives were set!
return h, nil
```
**Problem**: If no matching ruleset is found, the handler is returned without any rules, creating a no-op WAF that blocks nothing.
**Fix**: Return `nil` if no directives could be set:
```go
// FIXED:
h := Handler{"handler": "waf"}
directivesSet := false
if selected != nil {
if rulesetPaths != nil {
if p, ok := rulesetPaths[selected.Name]; ok && p != "" {
h["directives"] = fmt.Sprintf("Include %s", p)
directivesSet = true
}
}
} else if secCfg != nil && secCfg.WAFRulesSource != "" {
if rulesetPaths != nil {
if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" {
h["directives"] = fmt.Sprintf("Include %s", p)
directivesSet = true
}
}
}
if !directivesSet {
logger.Log().Warn("WAF enabled but no ruleset directives could be set")
return nil, nil // Don't create a useless handler
}
return h, nil
```
### Bug 3: Missing Debug Logging for Generated Config
**File**: [backend/internal/caddy/manager.go](../backend/internal/caddy/manager.go)
**Problem**: When the integration test fails, there's no easy way to see what Caddy config was actually generated and sent.
**Fix**: Add structured debug logging:
```go
// In ApplyConfig, after generating handlers:
logger.Log().WithFields(map[string]interface{}{
"waf_enabled": wafEnabled,
"ruleset_count": len(rulesets),
"ruleset_paths": rulesetPaths,
}).Debug("WAF configuration state")
```
### Bug 4: Integration Test Timing Issues
**File**: [scripts/coraza_integration.sh](../scripts/coraza_integration.sh)
**Problem**: The test creates a proxy host, then a ruleset, then security config. Each triggers `ApplyConfig`. The final config might not include the WAF handler if timing is off.
**Fix**: Add explicit config reload and verification:
```bash
# After setting up config, force a reload and verify
echo "Forcing Caddy config reload..."
curl -s http://localhost:8080/api/v1/caddy/reload || true
sleep 3
# Verify WAF handler is present in Caddy config
echo "Verifying WAF handler in Caddy config..."
CADDY_CONFIG=$(curl -s http://localhost:2019/config)
if echo "$CADDY_CONFIG" | grep -q '"handler":"waf"'; then
echo "✓ WAF handler found in Caddy config"
else
echo "✗ WAF handler NOT found in Caddy config"
echo "Caddy config dump:"
echo "$CADDY_CONFIG" | head -100
exit 1
fi
```
---
## 🏗️ Phase 1: Backend Implementation (Go)
### Task 1.1: Fix Ruleset Selection Priority
**File**: `backend/internal/caddy/config.go`
**Function**: `buildWAFHandler`
**Estimate**: 30 minutes
```go
// Replace lines 743-748 with:
for i, r := range rulesets {
// Priority order:
// 1. Exact match to secCfg.WAFRulesSource (user's global choice)
// 2. Exact match to hostRulesetName (per-host advanced_config)
// 3. Match to host.Application (app-specific defaults)
// 4. Fallback to owasp-crs
if (secCfg != nil && secCfg.WAFRulesSource != "" && r.Name == secCfg.WAFRulesSource) {
selected = &rulesets[i]
break
}
if hostRulesetName != "" && r.Name == hostRulesetName {
selected = &rulesets[i]
break
}
if host != nil && r.Name == host.Application {
selected = &rulesets[i]
break
}
if r.Name == "owasp-crs" && selected == nil {
selected = &rulesets[i]
// Don't break - keep looking for better matches
}
}
```
### Task 1.2: Add Validation for WAF Handler
**File**: `backend/internal/caddy/config.go`
**Function**: `buildWAFHandler`
**Estimate**: 30 minutes
Ensure the handler is only returned if it has valid directives. Log a warning otherwise.
### Task 1.3: Add Debug Logging
**File**: `backend/internal/caddy/manager.go`
**Function**: `ApplyConfig`
**Estimate**: 20 minutes
Add structured logging to capture WAF state during config generation.
### Task 1.4: Update Unit Tests
**Files**:
- `backend/internal/caddy/config_test.go`
- `backend/internal/caddy/manager_additional_test.go`
**Estimate**: 1 hour
- Test ruleset selection priority
- Test handler validation
- Test empty ruleset handling
---
## 🎨 Phase 2: Frontend (No Changes Required)
The frontend WAF configuration UI is working correctly. No changes needed.
---
## 🛠️ Phase 3: DevOps/CI Fixes
### Task 3.1: Improve Integration Test Robustness
**File**: `scripts/coraza_integration.sh`
**Estimate**: 45 minutes
```bash
#!/usr/bin/env bash
set -euo pipefail
# ... existing setup ...
# IMPROVEMENT 1: Add config verification step
verify_waf_config() {
local retries=5
local wait=2
for i in $(seq 1 $retries); do
CADDY_CONFIG=$(curl -s http://localhost:2019/config)
if echo "$CADDY_CONFIG" | grep -q '"handler":"waf"'; then
echo "✓ WAF handler verified in Caddy config"
# Also verify the directives include our ruleset
if echo "$CADDY_CONFIG" | grep -q "integration-xss"; then
echo "✓ Ruleset 'integration-xss' found in directives"
return 0
fi
fi
echo "Waiting for config to propagate (attempt $i/$retries)..."
sleep $wait
done
echo "✗ WAF handler verification failed after $retries attempts"
echo "Caddy config dump:"
curl -s http://localhost:2019/config | head -200
return 1
}
# IMPROVEMENT 2: Add container log dump on failure
on_failure() {
echo ""
echo "=== FAILURE DEBUG INFO ==="
echo ""
echo "=== Charon API Logs ==="
docker logs charon-debug 2>&1 | tail -100
echo ""
echo "=== Caddy Config ==="
curl -s http://localhost:2019/config | head -200
echo ""
echo "=== Ruleset Files ==="
docker exec charon-debug sh -c 'ls -la /app/data/caddy/coraza/rulesets/ 2>/dev/null' || echo "No rulesets found"
docker exec charon-debug sh -c 'cat /app/data/caddy/coraza/rulesets/*.conf 2>/dev/null' || echo "No ruleset content"
}
trap on_failure ERR
# ... rest of test with verify_waf_config calls ...
```
### Task 3.2: Add CI Debug Output
**File**: `.github/workflows/waf-integration.yml`
**Estimate**: 20 minutes
Add step to dump Caddy config on failure for easier debugging.
---
## 🕵️ Phase 4: QA & Testing
### Manual Test Checklist
1. **Block Mode Test**
- [ ] Create ruleset with XSS rule
- [ ] Set WAF mode to `block`
- [ ] Send `<script>` payload → Expect 403
2. **Monitor Mode Test**
- [ ] Set WAF mode to `monitor`
- [ ] Send `<script>` payload → Expect 200 (logged only)
- [ ] Verify log entry shows WAF detection
3. **Ruleset Priority Test**
- [ ] Create two rulesets: `test-rules` and `owasp-crs`
- [ ] Set `waf_rules_source` to `test-rules`
- [ ] Verify `test-rules` is used (not `owasp-crs`)
4. **Empty Ruleset Test**
- [ ] Enable WAF with no rulesets created
- [ ] Verify no WAF handler is added (not a broken one)
### CI Verification
After fixes are merged:
- [ ] WAF Integration workflow passes
- [ ] No regressions in main CI pipeline
---
## 📚 Phase 5: Documentation
### Task 5.1: Update Cerberus Docs
**File**: `docs/cerberus.md`
- Update WAF status from "Prototype" to "Functional"
- Document proper ruleset creation flow
- Add troubleshooting section
### Task 5.2: Add Debug Guide
**File**: `docs/debugging-waf.md` (new)
Document how to:
- Inspect Caddy config via admin API
- Check ruleset file contents
- Read WAF logs
---
## ⏱️ Timeline Estimate
| Phase | Task | Estimate |
|-------|------|----------|
| 1 | Backend fixes | 2.5 hours |
| 2 | Frontend | 0 hours |
| 3 | CI/DevOps | 1 hour |
| 4 | QA Testing | 1 hour |
| 5 | Documentation | 1 hour |
| **Total** | | **~5.5 hours** |
---
## 📎 Appendix: Technical Reference
### Coraza-Caddy Plugin Source
- Repository: https://github.com/corazawaf/coraza-caddy
- Module ID: `http.handlers.waf`
- JSON fields: `handler`, `directives`, `include` (deprecated), `load_owasp_crs`
### ModSecurity Directive Reference
- `SecRuleEngine On|Off|DetectionOnly`
- `SecRequestBodyAccess On|Off`
- `SecRule VARIABLE OPERATOR "ACTIONS"`
- `Include /path/to/file.conf`
### Charon WAF Flow
```
User creates ruleset → DB insert → ApplyConfig triggered
manager.go writes ruleset file with hash
config.go buildWAFHandler() creates handler with Include directive
Handler added to securityHandlers slice
JSON config sent to Caddy admin API
Caddy loads coraza-caddy plugin, parses directives, creates WAF instance
```

View File

@@ -8,12 +8,98 @@ set -euo pipefail
# 3. Wait for API to be ready and then configure a ruleset that blocks a simple signature
# 4. Request a path containing the signature and verify 403 (or WAF block response)
echo "Starting Coraza integration test..."
# Ensure we operate from repo root
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# ============================================================================
# Helper Functions
# ============================================================================
# Verifies WAF handler is present in Caddy config with correct ruleset
verify_waf_config() {
local expected_ruleset="${1:-integration-xss}"
local retries=10
local wait=3
echo "Verifying WAF config (expecting ruleset: ${expected_ruleset})..."
for i in $(seq 1 $retries); do
# Fetch Caddy config via admin API
local caddy_config
caddy_config=$(curl -s http://localhost:2019/config 2>/dev/null || echo "")
if [ -z "$caddy_config" ]; then
echo " Attempt $i/$retries: Caddy admin API not responding, retrying..."
sleep $wait
continue
fi
# Check for WAF handler
if echo "$caddy_config" | grep -q '"handler":"waf"'; then
echo " ✓ WAF handler found in Caddy config"
# Also verify the directives include our ruleset
if echo "$caddy_config" | grep -q "$expected_ruleset"; then
echo " ✓ Ruleset '${expected_ruleset}' found in directives"
return 0
else
echo " ⚠ WAF handler present but ruleset '${expected_ruleset}' not found in directives"
fi
else
echo " Attempt $i/$retries: WAF handler not found, waiting..."
fi
sleep $wait
done
echo " ✗ WAF handler verification failed after $retries attempts"
return 1
}
# Dumps debug information on failure
on_failure() {
local exit_code=$?
echo ""
echo "=============================================="
echo "=== FAILURE DEBUG INFO (exit code: $exit_code) ==="
echo "=============================================="
echo ""
echo "=== Charon API Logs (last 150 lines) ==="
docker logs charon-debug 2>&1 | tail -150 || echo "Could not retrieve container logs"
echo ""
echo "=== Caddy Admin API Config ==="
curl -s http://localhost:2019/config 2>/dev/null | head -300 || echo "Could not retrieve Caddy config"
echo ""
echo "=== Ruleset Files in Container ==="
docker exec charon-debug sh -c 'ls -la /app/data/caddy/coraza/rulesets/ 2>/dev/null' || echo "No rulesets directory found"
echo ""
echo "=== Ruleset File Contents ==="
docker exec charon-debug sh -c 'cat /app/data/caddy/coraza/rulesets/*.conf 2>/dev/null' || echo "No ruleset files found"
echo ""
echo "=== Security Config in API ==="
curl -s http://localhost:8080/api/v1/security/config 2>/dev/null || echo "Could not retrieve security config"
echo ""
echo "=== Proxy Hosts ==="
curl -s http://localhost:8080/api/v1/proxy-hosts 2>/dev/null | head -50 || echo "Could not retrieve proxy hosts"
echo ""
echo "=============================================="
echo "=== END DEBUG INFO ==="
echo "=============================================="
}
# Set up trap to dump debug info on any error
trap on_failure ERR
echo "Starting Coraza integration test..."
if ! command -v docker >/dev/null 2>&1; then
echo "docker is not available; aborting"
exit 1
@@ -99,18 +185,26 @@ echo "Enable WAF globally and set ruleset source to integration-xss..."
SEC_CFG_PAYLOAD='{"name":"default","enabled":true,"waf_mode":"block","waf_rules_source":"integration-xss","admin_whitelist":"0.0.0.0/0"}'
curl -s -X POST -H "Content-Type: application/json" -d "${SEC_CFG_PAYLOAD}" -b ${TMP_COOKIE} http://localhost:8080/api/v1/security/config
echo "Waiting for Caddy to apply WAF configuration..."
sleep 3
# Verify WAF handler is properly configured before proceeding
if ! verify_waf_config "integration-xss"; then
echo "ERROR: WAF configuration verification failed - aborting test"
exit 1
fi
echo "Apply rules and test payload..."
# create minimal proxy host if needed; omitted here for brevity; test will target local Caddy root
echo "Dumping Caddy config routes to verify waf handler and rules_files..."
curl -s http://localhost:2019/config | grep -n "waf" || true
curl -s http://localhost:2019/config | grep -n "integration-xss" || true
echo "Verifying Caddy config has WAF handler..."
curl -s http://localhost:2019/config | grep -E '"handler":"waf"' || echo "WARNING: WAF handler not found in initial config check"
echo "Inspecting ruleset file inside container..."
docker exec charon-debug sh -c 'cat /app/data/caddy/coraza/rulesets/integration-xss-*.conf' || true
docker exec charon-debug sh -c 'cat /app/data/caddy/coraza/rulesets/integration-xss-*.conf' || echo "WARNING: Could not read ruleset file"
echo "Recent caddy logs (may contain plugin errors):"
docker logs charon-debug | tail -n 200 || true
echo ""
echo "=== Testing BLOCK mode ==="
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -d "<script>alert(1)</script>" -H "Host: integration.local" http://localhost/post)
if [ "$RESPONSE" = "403" ]; then
@@ -129,6 +223,11 @@ curl -s -X POST -H "Content-Type: application/json" -d "${SEC_CFG_MONITOR}" -b $
echo "Wait for Caddy to apply monitor mode config..."
sleep 5
# Verify WAF handler is still present after mode switch
if ! verify_waf_config "integration-xss"; then
echo "WARNING: WAF config verification failed after mode switch, proceeding anyway..."
fi
echo "Inspecting ruleset file (should now have DetectionOnly)..."
docker exec charon-debug sh -c 'cat /app/data/caddy/coraza/rulesets/integration-xss-*.conf | head -5' || true
@@ -141,9 +240,6 @@ else
exit 1
fi
echo ""
echo "=== All Coraza integration tests passed ==="
echo ""
echo "=== All Coraza integration tests passed ==="
echo "Cleaning up..."