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