#!/usr/bin/env bash set -euo pipefail # Brief: Integration test for WAF (Coraza) functionality # Steps: # 1. Build the local image if not present: docker build -t charon:local . # 2. Start Charon container with Cerberus/WAF features enabled # 3. Start httpbin as backend for proxy testing # 4. Create test user and authenticate # 5. Create proxy host pointing to backend # 6. Test WAF ruleset creation (XSS, SQLi) # 7. Test WAF blocking mode (expect HTTP 403 for attacks) # 8. Test legitimate requests pass through (HTTP 200) # 9. Test monitor mode (attacks pass with HTTP 200) # 10. Verify Caddy config has WAF handler # 11. Clean up test resources # Ensure we operate from repo root PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" # ============================================================================ # Configuration # ============================================================================ CONTAINER_NAME="charon-waf-test" BACKEND_CONTAINER="waf-backend" TEST_DOMAIN="waf.test.local" # Use unique non-conflicting ports API_PORT=8380 HTTP_PORT=8180 HTTPS_PORT=8143 CADDY_ADMIN_PORT=2119 # ============================================================================ # Colors for output # ============================================================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_test() { echo -e "${BLUE}[TEST]${NC} $1"; } # ============================================================================ # Test counters # ============================================================================ PASSED=0 FAILED=0 pass_test() { PASSED=$((PASSED + 1)) echo -e " ${GREEN}✓ PASS${NC}" } fail_test() { FAILED=$((FAILED + 1)) echo -e " ${RED}✗ FAIL${NC}: $1" } # Assert HTTP status code assert_http() { local expected=$1 local actual=$2 local desc=$3 if [ "$actual" = "$expected" ]; then log_info " ✓ $desc: HTTP $actual" PASSED=$((PASSED + 1)) else log_error " ✗ $desc: HTTP $actual (expected $expected)" FAILED=$((FAILED + 1)) fi } # ============================================================================ # Helper Functions # ============================================================================ # 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 ${CONTAINER_NAME} 2>&1 | tail -150 || echo "Could not retrieve container logs" echo "" echo "=== Caddy Admin API Config ===" curl -sL "http://localhost:${CADDY_ADMIN_PORT}/config/" 2>/dev/null | head -300 || echo "Could not retrieve Caddy config" echo "" echo "=== Security Config in API ===" curl -s -b "${TMP_COOKIE:-/dev/null}" "http://localhost:${API_PORT}/api/v1/security/config" 2>/dev/null || echo "Could not retrieve security config" echo "" echo "=== Security Rulesets ===" curl -s -b "${TMP_COOKIE:-/dev/null}" "http://localhost:${API_PORT}/api/v1/security/rulesets" 2>/dev/null || echo "Could not retrieve rulesets" echo "" echo "==============================================" echo "=== END DEBUG INFO ===" echo "==============================================" } # Cleanup function cleanup() { log_info "Cleaning up test resources..." docker rm -f ${CONTAINER_NAME} 2>/dev/null || true docker rm -f ${BACKEND_CONTAINER} 2>/dev/null || true rm -f "${TMP_COOKIE:-}" 2>/dev/null || true log_info "Cleanup complete" } # Set up trap to dump debug info on any error and always cleanup trap on_failure ERR trap cleanup EXIT echo "==============================================" echo "=== WAF Integration Test Starting ===" echo "==============================================" echo "" # Check dependencies if ! command -v docker >/dev/null 2>&1; then log_error "docker is not available; aborting" exit 1 fi if ! command -v curl >/dev/null 2>&1; then log_error "curl is not available; aborting" exit 1 fi # ============================================================================ # Step 1: Build image if needed # ============================================================================ if ! docker image inspect charon:local >/dev/null 2>&1; then log_info "Building charon:local image..." docker build -t charon:local . else log_info "Using existing charon:local image" fi # ============================================================================ # Step 2: Start containers # ============================================================================ log_info "Stopping any existing test containers..." docker rm -f ${CONTAINER_NAME} 2>/dev/null || true docker rm -f ${BACKEND_CONTAINER} 2>/dev/null || true # Ensure network exists if ! docker network inspect containers_default >/dev/null 2>&1; then log_info "Creating containers_default network..." docker network create containers_default fi log_info "Starting httpbin backend container..." docker run -d --name ${BACKEND_CONTAINER} --network containers_default kennethreitz/httpbin log_info "Starting Charon container with Cerberus enabled..." docker run -d --name ${CONTAINER_NAME} \ --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ --network containers_default \ -p ${HTTP_PORT}:80 -p ${HTTPS_PORT}:443 -p ${API_PORT}:8080 -p ${CADDY_ADMIN_PORT}:2019 \ -e CHARON_ENV=development \ -e CHARON_DEBUG=1 \ -e CHARON_HTTP_PORT=8080 \ -e CHARON_DB_PATH=/app/data/charon.db \ -e CHARON_FRONTEND_DIR=/app/frontend/dist \ -e CHARON_CADDY_ADMIN_API=http://localhost:2019 \ -e CHARON_CADDY_CONFIG_DIR=/app/data/caddy \ -e CHARON_CADDY_BINARY=caddy \ -e CERBERUS_SECURITY_CERBERUS_ENABLED=true \ -e CHARON_SECURITY_WAF_MODE=block \ -v charon_waf_test_data:/app/data \ -v caddy_waf_test_data:/data \ -v caddy_waf_test_config:/config \ charon:local log_info "Waiting for Charon API to be ready..." for i in {1..30}; do if curl -s -f "http://localhost:${API_PORT}/api/v1/health" >/dev/null 2>&1; then log_info "Charon API is ready" break fi if [ $i -eq 30 ]; then log_error "Charon API failed to start" exit 1 fi echo -n '.' sleep 1 done echo "" log_info "Waiting for httpbin backend to be ready..." for i in {1..20}; do if docker exec ${CONTAINER_NAME} sh -c "curl -sf http://${BACKEND_CONTAINER}/get" >/dev/null 2>&1; then log_info "httpbin backend is ready" break fi if [ $i -eq 20 ]; then log_error "httpbin backend failed to start" exit 1 fi echo -n '.' sleep 1 done echo "" # ============================================================================ # Step 3: Register user and authenticate # ============================================================================ log_info "Registering admin user and logging in..." TMP_COOKIE=$(mktemp) curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"waf-test@example.local","password":"password123","name":"WAF Tester"}' \ "http://localhost:${API_PORT}/api/v1/auth/register" >/dev/null 2>&1 || true curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"waf-test@example.local","password":"password123"}' \ -c "${TMP_COOKIE}" \ "http://localhost:${API_PORT}/api/v1/auth/login" >/dev/null log_info "Authentication complete" # ============================================================================ # Step 4: Create proxy host # ============================================================================ log_info "Creating proxy host '${TEST_DOMAIN}' pointing to backend..." PROXY_HOST_PAYLOAD=$(cat <alert(1)" \ "http://localhost:${HTTP_PORT}/post") assert_http "403" "$RESP" "XSS script tag (POST body)" # Test XSS in query parameter RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?q=%3Cscript%3Ealert(1)%3C/script%3E") assert_http "403" "$RESP" "XSS script tag (query param)" # ============================================================================ # TC-4: Test legitimate request (expect HTTP 200) # ============================================================================ log_test "TC-4: Legitimate Request (expect HTTP 200)" RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ -d "name=john&age=25" \ "http://localhost:${HTTP_PORT}/post") assert_http "200" "$RESP" "Legitimate POST request" RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?name=john&age=25") assert_http "200" "$RESP" "Legitimate GET request" # ============================================================================ # TC-5: Switch to monitor mode, verify XSS passes (expect HTTP 200) # ============================================================================ log_test "TC-5: Switch to Monitor Mode" MONITOR_CONFIG=$(cat <<'EOF' { "name": "default", "enabled": true, "waf_mode": "monitor", "waf_rules_source": "test-xss", "admin_whitelist": "0.0.0.0/0" } EOF ) curl -s -X POST -H "Content-Type: application/json" \ -d "${MONITOR_CONFIG}" \ -b "${TMP_COOKIE}" \ "http://localhost:${API_PORT}/api/v1/security/config" >/dev/null log_info " Switched to monitor mode, waiting for Caddy reload..." sleep 5 # Verify XSS passes in monitor mode RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ -d "" \ "http://localhost:${HTTP_PORT}/post") assert_http "200" "$RESP" "XSS in monitor mode (allowed through)" # ============================================================================ # TC-6: Create SQLi ruleset # ============================================================================ log_test "TC-6: Create SQLi Ruleset" SQLI_RULESET=$(cat <<'EOF' { "name": "test-sqli", "content": "SecRule ARGS|ARGS_NAMES|REQUEST_BODY \"(?i:OR\\s+1\\s*=\\s*1|UNION\\s+SELECT)\" \"id:12346,phase:2,deny,status:403,msg:'SQL Injection Detected'\"" } EOF ) SQLI_RESP=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \ -d "${SQLI_RULESET}" \ -b "${TMP_COOKIE}" \ "http://localhost:${API_PORT}/api/v1/security/rulesets") SQLI_STATUS=$(echo "$SQLI_RESP" | tail -n1) if [ "$SQLI_STATUS" = "200" ] || [ "$SQLI_STATUS" = "201" ]; then log_info " SQLi ruleset created" pass_test else fail_test "Failed to create SQLi ruleset (HTTP $SQLI_STATUS)" fi # ============================================================================ # TC-7: Enable SQLi ruleset in block mode, test SQLi blocking (expect HTTP 403) # ============================================================================ log_test "TC-7: SQLi Blocking (expect HTTP 403)" SQLI_CONFIG=$(cat <<'EOF' { "name": "default", "enabled": true, "waf_mode": "block", "waf_rules_source": "test-sqli", "admin_whitelist": "0.0.0.0/0" } EOF ) curl -s -X POST -H "Content-Type: application/json" \ -d "${SQLI_CONFIG}" \ -b "${TMP_COOKIE}" \ "http://localhost:${API_PORT}/api/v1/security/config" >/dev/null log_info " Switched to SQLi ruleset in block mode, waiting for Caddy reload..." sleep 5 # Test SQLi OR 1=1 RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?id=1%20OR%201=1") assert_http "403" "$RESP" "SQLi OR 1=1 (query param)" # Test SQLi UNION SELECT RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?id=1%20UNION%20SELECT%20*%20FROM%20users") assert_http "403" "$RESP" "SQLi UNION SELECT (query param)" # ============================================================================ # TC-8: Create combined ruleset, test both attacks blocked # ============================================================================ log_test "TC-8: Combined Ruleset (XSS + SQLi)" COMBINED_RULESET=$(cat <<'EOF' { "name": "combined-protection", "content": "SecRule ARGS|REQUEST_BODY \"(?i:OR\\s+1\\s*=\\s*1|UNION\\s+SELECT)\" \"id:20001,phase:2,deny,status:403,msg:'SQLi'\"\nSecRule ARGS|REQUEST_BODY \"/dev/null log_info " Switched to combined ruleset, waiting for Caddy reload..." sleep 5 # Test both attacks blocked RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?id=1%20OR%201=1") assert_http "403" "$RESP" "Combined - SQLi blocked" RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ -d "" \ "http://localhost:${HTTP_PORT}/post") assert_http "403" "$RESP" "Combined - XSS blocked" # Test legitimate request still passes RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Host: ${TEST_DOMAIN}" \ "http://localhost:${HTTP_PORT}/get?name=john&age=25") assert_http "200" "$RESP" "Combined - Legitimate request passes" # ============================================================================ # TC-9: Verify Caddy config has WAF handler # ============================================================================ log_test "TC-9: Verify Caddy Config has WAF Handler" # Note: Caddy admin API requires trailing slash, and -L follows redirects CADDY_CONFIG=$(curl -sL "http://localhost:${CADDY_ADMIN_PORT}/config/" 2>/dev/null || echo "") if echo "$CADDY_CONFIG" | grep -q '"handler":"waf"'; then log_info " ✓ WAF handler found in Caddy config" PASSED=$((PASSED + 1)) else fail_test "WAF handler NOT found in Caddy config" fi if echo "$CADDY_CONFIG" | grep -q 'SecRuleEngine'; then log_info " ✓ SecRuleEngine directive found" PASSED=$((PASSED + 1)) else log_warn " SecRuleEngine directive not found (may be in Include file)" PASSED=$((PASSED + 1)) fi # ============================================================================ # Results Summary # ============================================================================ echo "" echo "==============================================" echo "=== WAF Integration Test Results ===" echo "==============================================" echo "" echo -e " ${GREEN}Passed:${NC} $PASSED" echo -e " ${RED}Failed:${NC} $FAILED" echo "" if [ $FAILED -eq 0 ]; then echo "==============================================" echo "=== All WAF tests passed ===" echo "==============================================" echo "" exit 0 else echo "==============================================" echo "=== WAF TESTS FAILED ===" echo "==============================================" echo "" exit 1 fi