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:
29
.github/workflows/waf-integration.yml
vendored
29
.github/workflows/waf-integration.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
283
backend/internal/caddy/config_waf_security_test.go
Normal file
283
backend/internal/caddy/config_waf_security_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
292
backend/internal/caddy/config_waf_test.go
Normal file
292
backend/internal/caddy/config_waf_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
405
docs/plans/CORAZA_WAF_FIX_PLAN.md
Normal file
405
docs/plans/CORAZA_WAF_FIX_PLAN.md
Normal 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
|
||||
```
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user