diff --git a/.github/workflows/waf-integration.yml b/.github/workflows/waf-integration.yml
new file mode 100644
index 00000000..625a99e8
--- /dev/null
+++ b/.github/workflows/waf-integration.yml
@@ -0,0 +1,74 @@
+name: WAF Integration Tests
+
+on:
+ push:
+ branches: [ main, development, 'feature/**' ]
+ paths:
+ - 'backend/internal/caddy/**'
+ - 'backend/internal/models/security*.go'
+ - 'scripts/coraza_integration.sh'
+ - 'Dockerfile'
+ - '.github/workflows/waf-integration.yml'
+ pull_request:
+ branches: [ main, development ]
+ paths:
+ - 'backend/internal/caddy/**'
+ - 'backend/internal/models/security*.go'
+ - 'scripts/coraza_integration.sh'
+ - 'Dockerfile'
+ - '.github/workflows/waf-integration.yml'
+ # Allow manual trigger
+ workflow_dispatch:
+
+jobs:
+ waf-integration:
+ name: Coraza WAF Integration
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+
+ - name: Build Docker image
+ run: |
+ docker build \
+ --build-arg VCS_REF=${{ github.sha }} \
+ -t charon:local .
+
+ - name: Run WAF integration tests
+ id: waf-test
+ run: |
+ chmod +x scripts/coraza_integration.sh
+ scripts/coraza_integration.sh 2>&1 | tee waf-test-output.txt
+ exit ${PIPESTATUS[0]}
+
+ - name: WAF Integration Summary
+ if: always()
+ run: |
+ echo "## 🛡️ WAF Integration Test Results" >> $GITHUB_STEP_SUMMARY
+ if [ "${{ steps.waf-test.outcome }}" == "success" ]; then
+ echo "✅ **All WAF tests passed**" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Test Results:" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep -E "^✓|^===|^Coraza" waf-test-output.txt || echo "See logs for details"
+ grep -E "^✓|^===|^Coraza" waf-test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ else
+ echo "❌ **WAF tests failed**" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Failure Details:" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep -E "^✗|Unexpected|Error|failed" waf-test-output.txt | head -20 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Cleanup
+ if: always()
+ run: |
+ docker rm -f charon-debug || true
+ docker rm -f coraza-backend || true
+ docker network rm containers_default || true
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index 3e10fbeb..96f293ec 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -121,11 +121,21 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
filePath := filepath.Join(corazaDir, safeName+".conf")
// Prepend required Coraza directives if not already present.
// These are essential for the WAF to actually enforce rules:
- // - SecRuleEngine On: enables blocking mode (default is DetectionOnly)
+ // - SecRuleEngine On: enables blocking mode (blocks malicious requests)
+ // - SecRuleEngine DetectionOnly: monitor mode (logs but doesn't block)
// - SecRequestBodyAccess On: allows inspecting POST body content
content := rs.Content
if !strings.Contains(strings.ToLower(content), "secruleengine") {
- content = "SecRuleEngine On\nSecRequestBodyAccess On\n\n" + content
+ // Determine WAF engine mode: per-ruleset mode takes precedence,
+ // then global WAFMode, defaulting to blocking if neither is set
+ engineMode := "On" // default to blocking
+ if rs.Mode == "detection" || rs.Mode == "monitor" {
+ engineMode = "DetectionOnly"
+ } else if rs.Mode == "" && secCfg.WAFMode == "monitor" {
+ // No per-ruleset mode set, use global WAFMode
+ engineMode = "DetectionOnly"
+ }
+ content = fmt.Sprintf("SecRuleEngine %s\nSecRequestBodyAccess On\n\n", engineMode) + content
}
// Write ruleset file with world-readable permissions so the Caddy
// process (which may run as an unprivileged user) can read it.
@@ -137,6 +147,34 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
logger.Log().WithField("ruleset", rs.Name).WithField("path", filePath).Info("wrote coraza ruleset file")
}
}
+
+ // Cleanup stale ruleset files that are no longer in the database
+ if entries, err := readDirFunc(corazaDir); err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ fileName := entry.Name()
+ filePath := filepath.Join(corazaDir, fileName)
+ // Check if this file is in the current rulesetPaths
+ isActive := false
+ for _, activePath := range rulesetPaths {
+ if activePath == filePath {
+ isActive = true
+ break
+ }
+ }
+ if !isActive {
+ if err := removeFileFunc(filePath); err != nil {
+ logger.Log().WithError(err).WithField("path", filePath).Warn("failed to remove stale ruleset file")
+ } else {
+ logger.Log().WithField("path", filePath).Info("removed stale ruleset file")
+ }
+ }
+ }
+ } else {
+ logger.Log().WithError(err).Warn("failed to read coraza rulesets dir for cleanup")
+ }
}
config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg)
diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go
index 3e2b46ce..2a179214 100644
--- a/backend/internal/caddy/manager_additional_test.go
+++ b/backend/internal/caddy/manager_additional_test.go
@@ -1148,3 +1148,316 @@ func TestManager_ApplyConfig_DebugMarshalFailure(t *testing.T) {
// ApplyConfig should still succeed even if debug logging fails
assert.NoError(t, manager.ApplyConfig(context.Background()))
}
+
+func TestManager_ApplyConfig_WAFModeMonitorUsesDetectionOnly(t *testing.T) {
+ tmp := t.TempDir()
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"wafmonitor")
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ assert.NoError(t, err)
+ assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.SecurityConfig{}, &models.SecurityRuleSet{}))
+
+ // Create host and ruleset
+ h := models.ProxyHost{DomainNames: "monitor.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
+ db.Create(&h)
+ // Ruleset content without SecRuleEngine
+ ruleContent := `SecRule REQUEST_BODY "" -H "Host: integration.local" http://localhost/post)
if [ "$RESPONSE" = "403" ]; then
- echo "Coraza WAF blocked payload as expected (HTTP 403)"
+ echo "✓ Coraza WAF blocked payload as expected (HTTP 403) in BLOCK mode"
else
- echo "Unexpected response code: $RESPONSE (expected 403)"
+ echo "✗ Unexpected response code: $RESPONSE (expected 403) in BLOCK mode"
exit 1
fi
-echo "Coraza integration test complete. Cleaning up..."
+echo ""
+echo "=== Testing MONITOR mode (DetectionOnly) ==="
+echo "Switching WAF to monitor mode..."
+SEC_CFG_MONITOR='{"name":"default","enabled":true,"waf_mode":"monitor","waf_rules_source":"integration-xss","admin_whitelist":"0.0.0.0/0"}'
+curl -s -X POST -H "Content-Type: application/json" -d "${SEC_CFG_MONITOR}" -b ${TMP_COOKIE} http://localhost:8080/api/v1/security/config
+
+echo "Wait for Caddy to apply monitor mode config..."
+sleep 2
+
+echo "Inspecting ruleset file (should now have DetectionOnly)..."
+docker exec charon-debug cat /app/data/caddy/coraza/rulesets/integration-xss.conf | head -5 || true
+
+RESPONSE_MONITOR=$(curl -s -o /dev/null -w "%{http_code}" -d "" -H "Host: integration.local" http://localhost/post)
+if [ "$RESPONSE_MONITOR" = "200" ]; then
+ echo "✓ Coraza WAF in MONITOR mode allowed payload through (HTTP 200) as expected"
+else
+ echo "✗ Unexpected response code: $RESPONSE_MONITOR (expected 200) in MONITOR mode"
+ echo " Note: Monitor mode should log but not block"
+ exit 1
+fi
+
+echo ""
+echo "=== All Coraza integration tests passed ==="
+
+echo ""
+echo "=== All Coraza integration tests passed ==="
+echo "Cleaning up..."
docker rm -f coraza-backend || true
if [ "$CREATED_NETWORK" -eq 1 ]; then
docker network rm containers_default || true