diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..6041699 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,252 @@ +name: Integration Tests + +on: + push: + branches: + - main + - master + - develop + pull_request: + branches: + - main + - master + - develop + +permissions: + contents: read + +jobs: + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install PowerShell and Pester + run: | + # Set non-interactive mode to avoid prompts + export DEBIAN_FRONTEND=noninteractive + + # Update package lists + sudo apt-get update + sudo apt-get install -y wget apt-transport-https software-properties-common + + # Download and install Microsoft signing key and repository + wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb" + + # Use dpkg with force-confdef and force-confold to handle config conflicts automatically + sudo dpkg --force-confdef --force-confold -i packages-microsoft-prod.deb + + # Install PowerShell + sudo apt-get update + sudo apt-get install -y powershell + + # Install Pester testing framework + pwsh -c "Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber" + + - name: Verify Docker Compose file + run: | + if [ ! -f "docker-compose.test.yml" ]; then + echo "โŒ docker-compose.test.yml not found" + exit 1 + fi + echo "โœ… Docker Compose test file found" + + # Validate Docker Compose file + docker compose -f docker-compose.test.yml config + + - name: Pull Docker Images + run: | + echo "๐Ÿ”„ Pulling required Docker images..." + docker compose -f docker-compose.test.yml pull + echo "โœ… Docker images pulled successfully" + + - name: Start Test Services + run: | + echo "๐Ÿ”„ Starting test services..." + docker compose -f docker-compose.test.yml up -d + echo "โœ… Test services started" + + echo "๐Ÿ“‹ Running containers:" + docker compose -f docker-compose.test.yml ps + + - name: Wait for Services to be Ready + shell: pwsh + run: | + Write-Host "๐Ÿ”„ Waiting for services to be ready..." -ForegroundColor Cyan + + function Test-ServiceHealth { + param([string]$Url, [string]$ServiceName, [int]$TimeoutSeconds = 30) + + Write-Host "Checking $ServiceName..." -ForegroundColor Yellow + $elapsed = 0 + $interval = 3 + + do { + try { + $response = Invoke-WebRequest -Uri $Url -Method Get -TimeoutSec 5 -UseBasicParsing + if ($response.StatusCode -eq 200) { + Write-Host "โœ… $ServiceName is ready!" -ForegroundColor Green + return $true + } + } catch { + # Service not ready, continue waiting + } + + Start-Sleep $interval + $elapsed += $interval + + if ($elapsed % 15 -eq 0) { + Write-Host " Still waiting for $ServiceName... ($elapsed/$TimeoutSeconds seconds)" -ForegroundColor Gray + } + + } while ($elapsed -lt $TimeoutSeconds) + + Write-Host "โŒ $ServiceName failed to become ready within $TimeoutSeconds seconds" -ForegroundColor Red + return $false + } + + # Test each service + $services = @( + @{ Url = "http://localhost:8080/api/rawdata"; Name = "Traefik API" }, + @{ Url = "http://localhost:8000/bypass"; Name = "Bypass Service" }, + @{ Url = "http://localhost:8000/protected"; Name = "Protected Service" } + ) + + $allReady = $true + foreach ($service in $services) { + if (-not (Test-ServiceHealth -Url $service.Url -ServiceName $service.Name)) { + $allReady = $false + break + } + } + + if (-not $allReady) { + Write-Host "โŒ Not all services became ready" -ForegroundColor Red + exit 1 + } + + Write-Host "โœ… All services are ready for testing!" -ForegroundColor Green + + - name: Run Integration Tests + shell: pwsh + run: | + Write-Host "๐Ÿงช Running Pester integration tests..." -ForegroundColor Cyan + + # Import Pester module + Import-Module Pester -Force + + # Configure Pester + $pesterConfig = New-PesterConfiguration + $pesterConfig.Run.Path = "./scripts/integration-tests.Tests.ps1" + $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.Run.Exit = $false + $pesterConfig.Run.PassThru = $true + + # Run tests + $result = Invoke-Pester -Configuration $pesterConfig + + # Report results + Write-Host "" + Write-Host "๐Ÿ“Š Test Results Summary:" -ForegroundColor Cyan + Write-Host " Total: $($result.TotalCount)" -ForegroundColor White + Write-Host " Passed: $($result.PassedCount)" -ForegroundColor Green + Write-Host " Failed: $($result.FailedCount)" -ForegroundColor Red + Write-Host " Skipped: $($result.SkippedCount)" -ForegroundColor Yellow + + if ($result.FailedCount -gt 0) { + Write-Host "โŒ $($result.FailedCount) test(s) failed" -ForegroundColor Red + exit 1 + } else { + Write-Host "โœ… All tests passed! ๐ŸŽ‰" -ForegroundColor Green + } + + - name: Show Container Logs on Failure + if: failure() + run: | + echo "๐Ÿ“‹ Container Status:" + docker compose -f docker-compose.test.yml ps + + echo "" + echo "๐Ÿ“ Service Logs:" + echo "==================== Traefik Logs ====================" + docker compose -f docker-compose.test.yml logs traefik --tail=50 + + echo "" + echo "==================== WAF Logs ====================" + docker compose -f docker-compose.test.yml logs waf --tail=50 + + echo "" + echo "==================== Protected Service Logs ====================" + docker compose -f docker-compose.test.yml logs whoami-protected --tail=50 + + echo "" + echo "==================== Bypass Service Logs ====================" + docker compose -f docker-compose.test.yml logs whoami-bypass --tail=50 + + - name: Cleanup Test Environment + if: always() + run: | + echo "๐Ÿงน Cleaning up test environment..." + docker compose -f docker-compose.test.yml down -v --remove-orphans + echo "โœ… Cleanup completed" + + # Additional job to test the PowerShell runner script + test-runner-script: + name: Test Runner Script Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install PowerShell + run: | + export DEBIAN_FRONTEND=noninteractive + sudo apt-get update + sudo apt-get install -y wget apt-transport-https software-properties-common + wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb" + sudo dpkg --force-confdef --force-confold -i packages-microsoft-prod.deb + sudo apt-get update + sudo apt-get install -y powershell + + - name: Validate Test Runner Script + shell: pwsh + run: | + # Check if the script exists and is valid PowerShell + if (-not (Test-Path "./Test-Integration.ps1")) { + Write-Host "โŒ Test-Integration.ps1 not found" -ForegroundColor Red + exit 1 + } + + # Basic syntax validation + try { + $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content "./Test-Integration.ps1" -Raw), [ref]$null) + Write-Host "โœ… Test-Integration.ps1 syntax is valid" -ForegroundColor Green + } catch { + Write-Host "โŒ Test-Integration.ps1 has syntax errors: $($_.Exception.Message)" -ForegroundColor Red + exit 1 + } + + # Check for required test files + $requiredFiles = @( + "./docker-compose.test.yml", + "./scripts/integration-tests.Tests.ps1" + ) + + foreach ($file in $requiredFiles) { + if (Test-Path $file) { + Write-Host "โœ… Found: $file" -ForegroundColor Green + } else { + Write-Host "โŒ Missing: $file" -ForegroundColor Red + exit 1 + } + } + + Write-Host "โœ… All required files are present" -ForegroundColor Green diff --git a/README.md b/README.md index 7ebcbfa..f348167 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ this is a fork of the original: https://github.com/acouvreur/traefik-modsecurity-plugin -This fork introduces alpine images, CRS 4.x suppport, a custom http.transport, and a 429 jail for repeat offenders +This fork introduces alpine images, CRS 4.x suppport, and a custom http.transport see: https://github.com/traefik/plugindemo#troubleshooting @@ -55,13 +55,22 @@ time. This plugin supports these configuration: +### Basic Configuration + * `modSecurityUrl`: (**mandatory**) it's the URL for the owasp/modsecurity container. -* `timeoutMillis`: (optional) timeout in milliseconds for the http client to talk with modsecurity container. (default 2 - seconds) -* `jailEnabled`: (optional) 429 jail for repeat offenders (based on threshold settings) -* `JailTimeDurationSecs`: (optional) how long a client will be jailed for, in seconds -* `badRequestsThresholdCount`: (optional) # of 403s a clientIP can trigger from OWASP before being adding to jail -* `badRequestsThresholdPeriodSecs` (optional) # the period, in seconds, that the threshold must meet before a client is added to the 429 jail +* `timeoutMillis`: (optional) timeout in milliseconds for the http client to talk with modsecurity container. (default 2000ms) +* `unhealthyWafBackOffPeriodSecs` (optional) the period, in seconds, to backoff if calls to modsecurity fail. Default to 0. Default behaviour is to send a 502 Bad Gateway when there are problems communicating with modsec. +* `modSecurityStatusRequestHeader`: (optional) name of the header to add to the request when requests are blocked by ModSecurity (for logging purposes). The header value will contain the HTTP status code returned by ModSecurity. Default is empty (no header added). + +### Advanced Transport Configuration + +These parameters allow fine-tuning of the HTTP client behavior for high-load scenarios: + +* `maxConnsPerHost`: (optional) maximum number of concurrent connections allowed per ModSecurity host. Set to 0 for unlimited connections (default: 0 - unlimited, original behavior). +* `maxIdleConnsPerHost`: (optional) maximum number of idle connections to keep per ModSecurity host. Set to 0 for unlimited idle connections (default: 0 - unlimited, original behavior). +* `responseHeaderTimeoutMillis`: (optional) timeout in milliseconds for waiting for response headers from ModSecurity. Set to 0 for no timeout (default: 0 - no timeout, original behavior). +* `expectContinueTimeoutMillis`: (optional) timeout in milliseconds for Expect: 100-continue handshake. Used for large payload uploads (default: 1000ms). + ## Local development (docker-compose.local.yml) diff --git a/Test-Integration.ps1 b/Test-Integration.ps1 new file mode 100644 index 0000000..ccf2fe0 --- /dev/null +++ b/Test-Integration.ps1 @@ -0,0 +1,301 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Runs integration tests for the Traefik ModSecurity Plugin + +.DESCRIPTION + This script starts the Docker Compose services, waits for them to be ready, + runs the Pester integration tests, and then cleans up the services. + +.PARAMETER SkipDockerCleanup + Skip stopping Docker services after tests complete (useful for debugging) + +.PARAMETER SkipWait + Skip waiting for services to be ready (assumes they're already running) + +.PARAMETER TestPath + Path to the Pester test file (defaults to ./scripts/integration-tests.Tests.ps1) + +.PARAMETER ComposeFile + Path to the Docker Compose file (defaults to ./docker-compose.test.yml) + +.EXAMPLE + ./Test-Integration.ps1 + Runs the full integration test suite + +.EXAMPLE + ./Test-Integration.ps1 -SkipDockerCleanup + Runs tests but leaves Docker services running for debugging + +.EXAMPLE + ./Test-Integration.ps1 -SkipWait + Runs tests assuming services are already running +#> + +[CmdletBinding()] +param( + [switch]$SkipDockerCleanup, + [switch]$SkipWait, + [string]$TestPath = "./scripts/integration-tests.Tests.ps1", + [string]$ComposeFile = "./docker-compose.test.yml" +) + +$ErrorActionPreference = "Stop" + +# Colors for output +$Colors = @{ + Info = "Cyan" + Success = "Green" + Warning = "Yellow" + Error = "Red" + Gray = "Gray" +} + +function Write-Step { + param([string]$Message, [string]$Color = "Cyan") + Write-Host "๐Ÿ”„ $Message" -ForegroundColor $Color +} + +function Write-Success { + param([string]$Message) + Write-Host "โœ… $Message" -ForegroundColor $Colors.Success +} + +function Write-Warning { + param([string]$Message) + Write-Host "โš ๏ธ $Message" -ForegroundColor $Colors.Warning +} + +function Write-Error { + param([string]$Message) + Write-Host "โŒ $Message" -ForegroundColor $Colors.Error +} + +function Test-ServiceHealth { + param( + [string]$Url, + [string]$ServiceName, + [int]$TimeoutSeconds = 30, + [int]$RetryIntervalSeconds = 3 + ) + + Write-Step "Waiting for $ServiceName to be ready..." + $elapsed = 0 + + do { + try { + $response = Invoke-WebRequest -Uri $Url -Method Get -TimeoutSec 5 -UseBasicParsing + if ($response.StatusCode -eq 200) { + Write-Success "$ServiceName is ready!" + return $true + } + } + catch { + # Service not ready yet, continue waiting + } + + Start-Sleep $RetryIntervalSeconds + $elapsed += $RetryIntervalSeconds + + if ($elapsed % 15 -eq 0) { + Write-Host " Still waiting for $ServiceName... ($elapsed/$TimeoutSeconds seconds)" -ForegroundColor $Colors.Gray + } + + } while ($elapsed -lt $TimeoutSeconds) + + Write-Error "$ServiceName failed to become ready within $TimeoutSeconds seconds" + return $false +} + +function Test-DockerCompose { + Write-Step "Checking Docker Compose availability..." + try { + $dockerComposeVersion = docker compose version 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Success "Docker Compose is available: $($dockerComposeVersion -split "`n" | Select-Object -First 1)" + } else { + throw "Docker Compose not found" + } + } + catch { + Write-Error "Docker Compose is not available. Please install Docker Desktop or Docker Compose." + return $false + } + return $true +} + +function Start-TestServices { + param([string]$ComposeFile) + + Write-Step "Starting Docker Compose services using $ComposeFile..." + try { + # Stop any existing containers first + docker compose -f $ComposeFile down -v --remove-orphans 2>$null | Out-Null + + # Start fresh containers + $output = docker compose -f $ComposeFile up -d 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "Docker Compose Output:" -ForegroundColor $Colors.Gray + Write-Host $output -ForegroundColor $Colors.Gray + throw "Failed to start Docker services (exit code: $LASTEXITCODE)" + } + Write-Success "Docker services started successfully" + + # Show running containers for verification + Write-Host "`nRunning containers:" -ForegroundColor $Colors.Info + docker compose -f $ComposeFile ps + + } + catch { + Write-Error "Failed to start Docker services: $($_.Exception.Message)" + throw + } +} + +function Wait-ForAllServices { + Write-Step "Waiting for all services to become ready..." + + $services = @( + @{ Url = "http://localhost:8080/api/rawdata"; Name = "Traefik API" }, + @{ Url = "http://localhost:8000/bypass"; Name = "Whoami Bypass service" }, + @{ Url = "http://localhost:8000/protected"; Name = "Whoami Protected service" } + ) + + $servicesReady = @() + foreach ($service in $services) { + $servicesReady += (Test-ServiceHealth -Url $service.Url -ServiceName $service.Name -TimeoutSeconds 30) + } + + if ($servicesReady -contains $false) { + Write-Error "One or more services failed to start properly" + Write-Host "`nContainer logs for debugging:" -ForegroundColor $Colors.Warning + docker compose -f $ComposeFile logs --tail=20 + return $false + } + + Write-Success "All services are ready for testing!" + return $true +} + +# Main execution +$exitCode = 0 +try { + Write-Host "" + Write-Host "๐Ÿš€ Traefik ModSecurity Plugin Integration Test Runner" -ForegroundColor $Colors.Info + Write-Host "=====================================================" -ForegroundColor $Colors.Info + Write-Host "" + + # Verify files exist + if (-not (Test-Path $ComposeFile)) { + Write-Error "Docker Compose file not found: $ComposeFile" + exit 1 + } + + if (-not (Test-Path $TestPath)) { + Write-Error "Test file not found: $TestPath" + exit 1 + } + + # Check if Pester is available + Write-Step "Checking Pester availability..." + try { + Import-Module Pester -Force -ErrorAction Stop + $pesterVersion = (Get-Module Pester).Version + Write-Success "Pester $pesterVersion is available" + } + catch { + Write-Warning "Pester module not found. Installing Pester..." + try { + Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force + Write-Success "Pester installed and imported successfully" + } + catch { + Write-Error "Failed to install Pester: $($_.Exception.Message)" + exit 1 + } + } + + # Check Docker Compose + if (-not (Test-DockerCompose)) { + exit 1 + } + + # Start Docker services + Start-TestServices -ComposeFile $ComposeFile + + if (-not $SkipWait) { + # Wait for services to be ready + if (-not (Wait-ForAllServices)) { + exit 1 + } + } else { + Write-Warning "Skipping service readiness check (assuming services are already running)" + } + + # Run Pester tests + Write-Step "Running Pester integration tests..." + Write-Host "" + + try { + $pesterConfig = New-PesterConfiguration + $pesterConfig.Run.Path = $TestPath + $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.Run.Exit = $false + $pesterConfig.Run.PassThru = $true + + # Run tests with timeout protection + $result = Invoke-Pester -Configuration $pesterConfig + + Write-Host "" + if ($result -and $result.FailedCount -eq 0) { + Write-Success "All integration tests passed! ๐ŸŽ‰" + Write-Host "๐Ÿ“Š Test Summary: $($result.PassedCount) passed, $($result.FailedCount) failed, $($result.SkippedCount) skipped" -ForegroundColor $Colors.Info + $exitCode = 0 + } elseif ($result) { + Write-Error "$($result.FailedCount) test(s) failed out of $($result.TotalCount) total tests" + Write-Host "๐Ÿ“Š Test Summary: $($result.PassedCount) passed, $($result.FailedCount) failed, $($result.SkippedCount) skipped" -ForegroundColor $Colors.Warning + $exitCode = 1 + } else { + Write-Warning "Could not determine test results" + $exitCode = 1 + } + } + catch { + Write-Error "Failed to run Pester tests: $($_.Exception.Message)" + $exitCode = 1 + } +} +catch { + Write-Error "Unexpected error: $($_.Exception.Message)" + $exitCode = 1 +} +finally { + # Cleanup Docker services + if (-not $SkipDockerCleanup) { + Write-Step "Cleaning up Docker services..." + try { + docker compose -f $ComposeFile down -v --remove-orphans 2>$null + Write-Success "Docker services stopped and cleaned up" + } + catch { + Write-Warning "Failed to clean up Docker services: $($_.Exception.Message)" + } + } else { + Write-Warning "Skipping Docker cleanup (services left running for debugging)" + Write-Host "To manually stop services, run: docker compose -f $ComposeFile down -v" -ForegroundColor $Colors.Gray + Write-Host "To view logs, run: docker compose -f $ComposeFile logs" -ForegroundColor $Colors.Gray + } + + Write-Host "" + Write-Host "=====================================================" -ForegroundColor $Colors.Info + if ($exitCode -eq 0) { + Write-Host "๐Ÿ Integration tests completed successfully!" -ForegroundColor $Colors.Success + } else { + Write-Host "๐Ÿ Integration tests completed with failures!" -ForegroundColor $Colors.Error + } + Write-Host "" +} + +exit $exitCode diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 89dfee8..533f559 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -23,8 +23,7 @@ services: # use traefiks built-in maxRequestBodyBytes middleware - there's no need for us to bake this ourselves - traefik.http.middlewares.limit.buffering.maxRequestBodyBytes=1048576 - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080 - - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.jailEnabled=true - + - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.unhealthyWafBackOffPeriodSecs=5 waf: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..b9dbddd --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,85 @@ +services: + traefik: + image: "traefik:v2.11.4" + ports: + - "8080:8080" # Traefik API + - "8000:80" # HTTP + command: + - "--log.level=INFO" + - "--api.dashboard=true" + - "--api.insecure=true" + - "--accesslog" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--accesslog.format=json" + - "--accesslog.fields.headers.names.X-Waf-Status=keep" + - "--experimental.localPlugins.traefik-modsecurity-plugin.moduleName=github.com/madebymode/traefik-modsecurity-plugin" + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - ".:/plugins-local/src/github.com/madebymode/traefik-modsecurity-plugin" + - logs-local:/var/log/traefik + + waf: + image: owasp/modsecurity-crs:4.3.0-apache-alpine-202406090906 + environment: + - PARANOIA=1 + - ANOMALY_INBOUND=5 + - ANOMALY_OUTBOUND=4 + - BACKEND=http://dummy + - MODSEC_RULE_ENGINE=On + - MODSEC_REQ_BODY_LIMIT=10485760 # 10MB limit + - MODSEC_REQ_BODY_NOFILES_LIMIT=1048576 # 1MB limit for non-file uploads + + dummy: + image: traefik/whoami + + # Protected by WAF + whoami-protected: + image: traefik/whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.protected.rule=PathPrefix(`/protected`)" + - "traefik.http.routers.protected.middlewares=waf-middleware" + - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080" + - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.timeoutMillis=3000" + - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.modSecurityStatusRequestHeader=X-Waf-Status" + # Optional: Configure transport parameters for high-load scenarios + # - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.maxConnsPerHost=20" + # - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.maxIdleConnsPerHost=10" + # - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.responseHeaderTimeoutMillis=5000" + # - "traefik.http.middlewares.waf-middleware.plugin.traefik-modsecurity-plugin.expectContinueTimeoutMillis=2000" + + # No WAF protection + whoami-bypass: + image: traefik/whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.bypass.rule=PathPrefix(`/bypass`)" + + # Test service for remediation header testing + whoami-remediation-test: + image: traefik/whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.remediation-test.rule=PathPrefix(`/remediation-test`)" + - "traefik.http.routers.remediation-test.middlewares=waf-remediation-middleware" + - "traefik.http.middlewares.waf-remediation-middleware.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080" + - "traefik.http.middlewares.waf-remediation-middleware.plugin.traefik-modsecurity-plugin.timeoutMillis=3000" + - "traefik.http.middlewares.waf-remediation-middleware.plugin.traefik-modsecurity-plugin.modSecurityStatusRequestHeader=X-Waf-Status" + - "traefik.http.middlewares.waf-remediation-middleware.plugin.traefik-modsecurity-plugin.unhealthyWafBackOffPeriodSecs=5" + + # Test service for error header testing (invalid ModSecurity URL) + whoami-error-test: + image: traefik/whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.error-test.rule=PathPrefix(`/error-test`)" + - "traefik.http.routers.error-test.middlewares=waf-error-middleware" + - "traefik.http.middlewares.waf-error-middleware.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://invalid-waf-url:9999" + - "traefik.http.middlewares.waf-error-middleware.plugin.traefik-modsecurity-plugin.timeoutMillis=1000" + - "traefik.http.middlewares.waf-error-middleware.plugin.traefik-modsecurity-plugin.modSecurityStatusRequestHeader=X-Waf-Status" + - "traefik.http.middlewares.waf-error-middleware.plugin.traefik-modsecurity-plugin.unhealthyWafBackOffPeriodSecs=5" + +volumes: + logs-local: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 360dedc..df12004 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,6 @@ services: # use traefiks built-in maxRequestBodyBytes middleware - there's no need for us to bake this ourselves - traefik.http.middlewares.limit.buffering.maxRequestBodyBytes=1048576 - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080 - - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.jailEnabled=true waf: image: owasp/modsecurity-crs:4.3.0-apache-alpine-202406090906 diff --git a/modsecurity.go b/modsecurity.go index b9dfff8..9526313 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -19,20 +19,24 @@ import ( type Config struct { TimeoutMillis int64 `json:"timeoutMillis,omitempty"` ModSecurityUrl string `json:"modSecurityUrl,omitempty"` - JailEnabled bool `json:"jailEnabled,omitempty"` - BadRequestsThresholdCount int `json:"badRequestsThresholdCount,omitempty"` - BadRequestsThresholdPeriodSecs int `json:"badRequestsThresholdPeriodSecs,omitempty"` // Period in seconds to track attempts - JailTimeDurationSecs int `json:"jailTimeDurationSecs,omitempty"` // How long a client spends in Jail in seconds + UnhealthyWafBackOffPeriodSecs int `json:"unhealthyWafBackOffPeriodSecs,omitempty"` // If the WAF is unhealthy, back off + ModSecurityStatusRequestHeader string `json:"modSecurityStatusRequestHeader,omitempty"` // Header name to add to request when blocked (for logging) + MaxConnsPerHost int `json:"maxConnsPerHost,omitempty"` // Maximum connections per host (0 = unlimited, original default) + MaxIdleConnsPerHost int `json:"maxIdleConnsPerHost,omitempty"` // Maximum idle connections per host (0 = unlimited, original default) + ResponseHeaderTimeoutMillis int64 `json:"responseHeaderTimeoutMillis,omitempty"` // Timeout for response headers (0 = no timeout, original default) + ExpectContinueTimeoutMillis int64 `json:"expectContinueTimeoutMillis,omitempty"` // Timeout for Expect: 100-continue (default 1000ms) } // CreateConfig creates the default plugin configuration. func CreateConfig() *Config { return &Config{ - TimeoutMillis: 2000, - JailEnabled: false, - BadRequestsThresholdCount: 25, - BadRequestsThresholdPeriodSecs: 600, - JailTimeDurationSecs: 600, + TimeoutMillis: 2000, // Original default: 2 seconds + UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) + ModSecurityStatusRequestHeader: "", // Empty string means no header will be added + MaxConnsPerHost: 0, // 0 = unlimited connections per host (original default) + MaxIdleConnsPerHost: 0, // 0 = unlimited idle connections per host (original default) + ResponseHeaderTimeoutMillis: 0, // 0 = no response header timeout (original default) + ExpectContinueTimeoutMillis: 1000, // 1 second (original default) } } @@ -43,13 +47,10 @@ type Modsecurity struct { name string httpClient *http.Client logger *log.Logger - jailEnabled bool - badRequestsThresholdCount int - badRequestsThresholdPeriodSecs int - jailTimeDurationSecs int - jail map[string][]time.Time - jailRelease map[string]time.Time - jailMutex sync.RWMutex + unhealthyWafBackOffPeriodSecs int + unhealthyWaf bool // If the WAF is unhealthy + unhealthyWafMutex sync.Mutex + modSecurityStatusRequestHeader string // Header name to add to request when blocked (for logging) } // New creates a new Modsecurity plugin with the given configuration. @@ -59,10 +60,10 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h return nil, fmt.Errorf("modSecurityUrl cannot be empty") } - // Use a custom client with predefined timeout of 2 seconds + // Use a custom client with configurable timeout var timeout time.Duration if config.TimeoutMillis == 0 { - timeout = 2 * time.Second + timeout = 2 * time.Second // Original default: 2 seconds } else { timeout = time.Duration(config.TimeoutMillis) * time.Millisecond } @@ -73,7 +74,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h KeepAlive: 30 * time.Second, } - // transport is a custom http.Transport with various timeouts and configurations for optimal performance. + // transport is a custom http.Transport with configurable timeouts and connection limits transport := &http.Transport{ MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, @@ -88,18 +89,32 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h }, } + // Configure connection limits (0 = unlimited, original behavior) + if config.MaxConnsPerHost > 0 { + transport.MaxConnsPerHost = config.MaxConnsPerHost + } + if config.MaxIdleConnsPerHost > 0 { + transport.MaxIdleConnsPerHost = config.MaxIdleConnsPerHost + } + + // Configure response header timeout (0 = no timeout, original behavior) + if config.ResponseHeaderTimeoutMillis > 0 { + transport.ResponseHeaderTimeout = time.Duration(config.ResponseHeaderTimeoutMillis) * time.Millisecond + } + + // Configure Expect: 100-continue timeout + if config.ExpectContinueTimeoutMillis > 0 { + transport.ExpectContinueTimeout = time.Duration(config.ExpectContinueTimeoutMillis) * time.Millisecond + } + return &Modsecurity{ modSecurityUrl: config.ModSecurityUrl, next: next, name: name, httpClient: &http.Client{Timeout: timeout, Transport: transport}, logger: log.New(os.Stdout, "", log.LstdFlags), - jailEnabled: config.JailEnabled, - badRequestsThresholdCount: config.BadRequestsThresholdCount, - badRequestsThresholdPeriodSecs: config.BadRequestsThresholdPeriodSecs, - jailTimeDurationSecs: config.JailTimeDurationSecs, - jail: make(map[string][]time.Time), - jailRelease: make(map[string]time.Time), + unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, + modSecurityStatusRequestHeader: config.ModSecurityStatusRequestHeader, }, nil } @@ -109,18 +124,13 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - clientIP := req.RemoteAddr - - // Check if the client is in jail, if jail is enabled - if a.jailEnabled { - a.jailMutex.RLock() - if a.isClientInJail(clientIP) { - a.jailMutex.RUnlock() - a.logger.Printf("client %s is jailed", clientIP) - http.Error(rw, "Too Many Requests", http.StatusTooManyRequests) - return + // If the WAF is unhealthy just forward the request early. No concurrency control here on purpose. + if a.unhealthyWaf { + if a.modSecurityStatusRequestHeader != "" { + req.Header.Set(a.modSecurityStatusRequestHeader, "unhealthy") } - a.jailMutex.RUnlock() + a.next.ServeHTTP(rw, req) + return } // Buffer the body if we want to read it here and send it in the request. @@ -132,11 +142,13 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } req.Body = io.NopCloser(bytes.NewReader(body)) - // Create a new URL from the raw RequestURI sent by the client - url := fmt.Sprintf("%s%s", a.modSecurityUrl, req.RequestURI) + url := a.modSecurityUrl + req.RequestURI proxyReq, err := http.NewRequest(req.Method, url, bytes.NewReader(body)) if err != nil { + if a.modSecurityStatusRequestHeader != "" { + req.Header.Set(a.modSecurityStatusRequestHeader, "cannotforward") + } a.logger.Printf("fail to prepare forwarded request: %s", err.Error()) http.Error(rw, "", http.StatusBadGateway) return @@ -150,6 +162,26 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { resp, err := a.httpClient.Do(proxyReq) if err != nil { + if a.unhealthyWafBackOffPeriodSecs > 0 { + a.unhealthyWafMutex.Lock() + if !a.unhealthyWaf { + a.logger.Printf("marking modsec as unhealthy for %ds fail to send HTTP request to modsec: %s", a.unhealthyWafBackOffPeriodSecs, err.Error()) + a.unhealthyWaf = true + if a.modSecurityStatusRequestHeader != "" { + req.Header.Set(a.modSecurityStatusRequestHeader, "error") + } + time.AfterFunc(time.Duration(a.unhealthyWafBackOffPeriodSecs)*time.Second, func() { + a.unhealthyWafMutex.Lock() + defer a.unhealthyWafMutex.Unlock() + a.unhealthyWaf = false + a.logger.Printf("modsec unhealthy backoff expired") + }) + } + a.unhealthyWafMutex.Unlock() + a.next.ServeHTTP(rw, req) + return + } + a.logger.Printf("fail to send HTTP request to modsec: %s", err.Error()) http.Error(rw, "", http.StatusBadGateway) return @@ -157,8 +189,9 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { defer resp.Body.Close() if resp.StatusCode >= 400 { - if resp.StatusCode == http.StatusForbidden && a.jailEnabled { - a.recordOffense(clientIP) + // Add remediation header to request if configured (for logging purposes) + if a.modSecurityStatusRequestHeader != "" { + req.Header.Set(a.modSecurityStatusRequestHeader, fmt.Sprintf("%d", resp.StatusCode)) } forwardResponse(resp, rw) return @@ -177,59 +210,12 @@ func isWebsocket(req *http.Request) bool { } func forwardResponse(resp *http.Response, rw http.ResponseWriter) { - // Copy headers + dst := rw.Header() for k, vv := range resp.Header { - for _, v := range vv { - rw.Header().Add(k, v) - } + dst[k] = append(dst[k][:0], vv...) } // Copy status rw.WriteHeader(resp.StatusCode) // Copy body io.Copy(rw, resp.Body) } - -func (a *Modsecurity) recordOffense(clientIP string) { - a.jailMutex.Lock() - defer a.jailMutex.Unlock() - - now := time.Now() - // Remove offenses that are older than the threshold period - if offenses, exists := a.jail[clientIP]; exists { - var newOffenses []time.Time - for _, offense := range offenses { - if now.Sub(offense) <= time.Duration(a.badRequestsThresholdPeriodSecs)*time.Second { - newOffenses = append(newOffenses, offense) - } - } - a.jail[clientIP] = newOffenses - } - - // Record the new offense - a.jail[clientIP] = append(a.jail[clientIP], now) - - // Check if the client should be jailed - if len(a.jail[clientIP]) >= a.badRequestsThresholdCount { - a.logger.Printf("client %s reached threshold, putting in jail", clientIP) - a.jailRelease[clientIP] = now.Add(time.Duration(a.jailTimeDurationSecs) * time.Second) - } -} - -func (a *Modsecurity) isClientInJail(clientIP string) bool { - if releaseTime, exists := a.jailRelease[clientIP]; exists { - if time.Now().Before(releaseTime) { - return true - } - a.releaseFromJail(clientIP) - } - return false -} - -func (a *Modsecurity) releaseFromJail(clientIP string) { - a.jailMutex.Lock() - defer a.jailMutex.Unlock() - - delete(a.jail, clientIP) - delete(a.jailRelease, clientIP) - a.logger.Printf("client %s released from jail", clientIP) -} diff --git a/modsecurity_test.go b/modsecurity_test.go index 41054da..432789c 100644 --- a/modsecurity_test.go +++ b/modsecurity_test.go @@ -3,12 +3,13 @@ package traefik_modsecurity_plugin import ( "bytes" "context" - "github.com/stretchr/testify/assert" "io" "log" "net/http" "net/http/httptest" "testing" + + "github.com/stretchr/testify/assert" ) func TestModsecurity_ServeHTTP(t *testing.T) { @@ -30,75 +31,86 @@ func TestModsecurity_ServeHTTP(t *testing.T) { } tests := []struct { - name string - request *http.Request - wafResponse response - serviceResponse response - expectBody string - expectStatus int - jailEnabled bool - jailConfig *Config + name string + request *http.Request + wafResponse response + serviceResponse response + expectBody string + expectStatus int + modSecurityStatusRequestHeader string + expectHeader string + expectHeaderValue string }{ { - name: "Forward request when WAF found no threats", - request: req.Clone(req.Context()), - wafResponse: response{ - StatusCode: 200, - Body: "Response from waf", - }, - serviceResponse: serviceResponse, - expectBody: "Response from service", - expectStatus: 200, - jailEnabled: false, + name: "Forward request when WAF found no threats", + request: req.Clone(req.Context()), + wafResponse: response{StatusCode: 200, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from service", + expectStatus: 200, + modSecurityStatusRequestHeader: "", + expectHeader: "", + expectHeaderValue: "", }, { - name: "Intercepts request when WAF found threats", - request: req.Clone(req.Context()), - wafResponse: response{ - StatusCode: 403, - Body: "Response from waf", - }, - serviceResponse: serviceResponse, - expectBody: "Response from waf", - expectStatus: 403, - jailEnabled: false, + name: "Intercepts request when WAF found threats", + request: req.Clone(req.Context()), + wafResponse: response{StatusCode: 403, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from waf", + expectStatus: 403, + modSecurityStatusRequestHeader: "", + expectHeader: "", + expectHeaderValue: "", }, { name: "Does not forward Websockets", request: &http.Request{ - Body: http.NoBody, - Header: http.Header{ - "Upgrade": []string{"websocket"}, - }, + Body: http.NoBody, + Header: http.Header{"Upgrade": []string{"websocket"}}, Method: http.MethodGet, URL: req.URL, }, - wafResponse: response{ - StatusCode: 200, - Body: "Response from waf", - }, - serviceResponse: serviceResponse, - expectBody: "Response from service", - expectStatus: 200, - jailEnabled: false, + wafResponse: response{StatusCode: 200, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from service", + expectStatus: 200, + modSecurityStatusRequestHeader: "", + expectHeader: "", + expectHeaderValue: "", }, { - name: "Jail client after multiple bad requests", - request: req.Clone(req.Context()), - wafResponse: response{ - StatusCode: 403, - Body: "Response from waf", - }, - serviceResponse: serviceResponse, - expectBody: "Too Many Requests\n", - expectStatus: http.StatusTooManyRequests, - jailEnabled: true, - jailConfig: &Config{ - JailEnabled: true, - BadRequestsThresholdCount: 3, - BadRequestsThresholdPeriodSecs: 10, - JailTimeDurationSecs: 10, - }, + name: "Adds remediation header when request is blocked", + request: req.Clone(req.Context()), + wafResponse: response{StatusCode: 403, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from waf", + expectStatus: 403, + modSecurityStatusRequestHeader: "X-Waf-Block", + expectHeader: "X-Waf-Block", + expectHeaderValue: "403", + }, + { + name: "Does not add remediation header when request is allowed", + request: req.Clone(req.Context()), + wafResponse: response{StatusCode: 200, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from service", + expectStatus: 200, + modSecurityStatusRequestHeader: "X-Waf-Block", + expectHeader: "", + expectHeaderValue: "", + }, + { + name: "Adds remediation header with different status codes", + request: req.Clone(req.Context()), + wafResponse: response{StatusCode: 406, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from waf", + expectStatus: 406, + modSecurityStatusRequestHeader: "X-Remediation-Info", + expectHeader: "X-Remediation-Info", + expectHeaderValue: "406", }, } @@ -126,17 +138,9 @@ func TestModsecurity_ServeHTTP(t *testing.T) { }) config := &Config{ - TimeoutMillis: 2000, - ModSecurityUrl: modsecurityMockServer.URL, - JailEnabled: tt.jailEnabled, - BadRequestsThresholdCount: 25, - BadRequestsThresholdPeriodSecs: 600, - JailTimeDurationSecs: 600, - } - - if tt.jailEnabled && tt.jailConfig != nil { - config = tt.jailConfig - config.ModSecurityUrl = modsecurityMockServer.URL + TimeoutMillis: 2000, + ModSecurityUrl: modsecurityMockServer.URL, + ModSecurityStatusRequestHeader: tt.modSecurityStatusRequestHeader, } middleware, err := New(context.Background(), httpServiceHandler, config, "modsecurity-middleware") @@ -145,20 +149,21 @@ func TestModsecurity_ServeHTTP(t *testing.T) { } rw := httptest.NewRecorder() - - for i := 0; i < config.BadRequestsThresholdCount; i++ { - middleware.ServeHTTP(rw, tt.request.Clone(tt.request.Context())) - if tt.jailEnabled && i < config.BadRequestsThresholdCount-1 { - assert.Equal(t, tt.wafResponse.StatusCode, rw.Result().StatusCode) - } - } - - rw = httptest.NewRecorder() middleware.ServeHTTP(rw, tt.request.Clone(tt.request.Context())) resp := rw.Result() body, _ := io.ReadAll(resp.Body) assert.Equal(t, tt.expectBody, string(body)) assert.Equal(t, tt.expectStatus, resp.StatusCode) + + // Check for expected status header in request (not response) + if tt.expectHeader != "" { + assert.Equal(t, tt.expectHeaderValue, tt.request.Header.Get(tt.expectHeader), "Expected status header in request with correct value") + } else { + // When no header is expected, ensure no status header was added to request + if tt.modSecurityStatusRequestHeader != "" { + assert.Empty(t, tt.request.Header.Get(tt.modSecurityStatusRequestHeader), "No status header should be present in request") + } + } }) } } diff --git a/scripts/TestHelpers.ps1 b/scripts/TestHelpers.ps1 new file mode 100644 index 0000000..795a821 --- /dev/null +++ b/scripts/TestHelpers.ps1 @@ -0,0 +1,323 @@ +# PowerShell Test Helper Functions for Traefik ModSecurity Plugin +# These functions can be reused across multiple test files + +# Test configuration constants +$script:DefaultTimeout = 15 +$script:DefaultRetryInterval = 2 + +<# +.SYNOPSIS + Makes HTTP requests with comprehensive error handling + +.DESCRIPTION + A robust wrapper around Invoke-WebRequest with consistent error handling, + timeout management, and optional security bypass for testing scenarios + +.PARAMETER Uri + The URL to make the request to + +.PARAMETER Method + HTTP method (GET, POST, etc.) + +.PARAMETER Headers + Hash table of headers to include + +.PARAMETER Body + Request body content + +.PARAMETER TimeoutSec + Request timeout in seconds + +.PARAMETER AllowInsecure + Skip certificate validation for HTTPS +#> +function Invoke-SafeWebRequest { + param( + [Parameter(Mandatory)] + [string]$Uri, + [string]$Method = "GET", + [hashtable]$Headers = @{}, + [string]$Body = $null, + [int]$TimeoutSec = 10, + [switch]$AllowInsecure + ) + + try { + $params = @{ + Uri = $Uri + Method = $Method + Headers = $Headers + TimeoutSec = $TimeoutSec + UseBasicParsing = $true + } + + if ($Body) { + $params.Body = $Body + } + + if ($AllowInsecure) { + $params.SkipCertificateCheck = $true + } + + return Invoke-WebRequest @params + } + catch { + Write-Host "Request failed: $($_.Exception.Message)" -ForegroundColor Yellow + throw + } +} + +<# +.SYNOPSIS + Waits for a service to become ready by checking its health endpoint + +.DESCRIPTION + Polls a service endpoint until it returns a successful response or timeout is reached. + Uses exponential backoff for efficient waiting. + +.PARAMETER Url + The health check URL for the service + +.PARAMETER ServiceName + Human-readable name for logging + +.PARAMETER TimeoutSeconds + Maximum time to wait before giving up + +.PARAMETER RetryInterval + Time between retry attempts in seconds +#> +function Wait-ForService { + param( + [Parameter(Mandatory)] + [string]$Url, + [Parameter(Mandatory)] + [string]$ServiceName, + [int]$TimeoutSeconds = 30, + [int]$RetryInterval = 2 + ) + + Write-Host "Waiting for $ServiceName to be ready..." -ForegroundColor Cyan + $elapsed = 0 + + do { + try { + $response = Invoke-SafeWebRequest -Uri $Url -TimeoutSec 5 + if ($response.StatusCode -eq 200) { + Write-Host "โœ… $ServiceName is ready!" -ForegroundColor Green + return $true + } + } + catch { + # Service not ready yet, continue waiting + } + + Start-Sleep $RetryInterval + $elapsed += $RetryInterval + + if ($elapsed % 10 -eq 0) { + Write-Host " Still waiting for $ServiceName... ($elapsed/$TimeoutSeconds seconds)" -ForegroundColor Gray + } + + } while ($elapsed -lt $TimeoutSeconds) + + Write-Host "โŒ $ServiceName failed to become ready within $TimeoutSeconds seconds" -ForegroundColor Red + return $false +} + +<# +.SYNOPSIS + Tests multiple services for readiness + +.PARAMETER Services + Array of service objects with Url and Name properties + +.PARAMETER TimeoutSeconds + Per-service timeout in seconds +#> +function Wait-ForAllServices { + param( + [Parameter(Mandatory)] + [array]$Services, + [int]$TimeoutSeconds = 30 + ) + + Write-Host "`n๐Ÿ”„ Waiting for all services to be ready..." -ForegroundColor Cyan + + $servicesReady = @() + foreach ($service in $Services) { + $servicesReady += (Wait-ForService -Url $service.Url -ServiceName $service.Name -TimeoutSeconds $TimeoutSeconds) + } + + if ($servicesReady -contains $false) { + throw "One or more services failed to start properly" + } + + Write-Host "โœ… All services are ready for testing!`n" -ForegroundColor Green + return $true +} + +<# +.SYNOPSIS + Tests if a request is blocked by WAF + +.DESCRIPTION + Attempts a potentially malicious request and verifies it gets blocked + with an appropriate HTTP error status + +.PARAMETER Url + The URL to test (should include malicious payload) + +.PARAMETER ExpectedMinStatus + Minimum expected HTTP status code for blocked requests (default: 400) +#> +function Test-WafBlocking { + param( + [Parameter(Mandatory)] + [string]$Url, + [int]$ExpectedMinStatus = 400 + ) + + try { + $response = Invoke-SafeWebRequest -Uri $Url + # If we get here, the request wasn't blocked + throw "Expected request to be blocked but got status: $($response.StatusCode)" + } + catch [Microsoft.PowerShell.Commands.HttpResponseException] { + # Expected - request was blocked + $response = $_.Exception.Response + if ($response) { + $statusCode = [int]$response.StatusCode + $statusCode | Should -BeGreaterOrEqual $ExpectedMinStatus + Write-Host "โœ… WAF blocked request with status: $statusCode" -ForegroundColor Green + return $statusCode + } + } +} + +<# +.SYNOPSIS + Tests multiple malicious patterns to ensure they're blocked + +.PARAMETER BaseUrl + Base URL for the protected endpoint + +.PARAMETER Patterns + Array of malicious query string patterns to test +#> +function Test-MaliciousPatterns { + param( + [Parameter(Mandatory)] + [string]$BaseUrl, + [Parameter(Mandatory)] + [array]$Patterns + ) + + foreach ($pattern in $Patterns) { + $testUrl = "$BaseUrl$pattern" + Test-WafBlocking -Url $testUrl + Write-Host "โœ… Pattern blocked: $pattern" -ForegroundColor Green + } +} + +<# +.SYNOPSIS + Tests multiple patterns to ensure they're allowed through + +.PARAMETER BaseUrl + Base URL for the bypass endpoint + +.PARAMETER Patterns + Array of query string patterns that should be allowed +#> +function Test-BypassPatterns { + param( + [Parameter(Mandatory)] + [string]$BaseUrl, + [Parameter(Mandatory)] + [array]$Patterns + ) + + foreach ($pattern in $Patterns) { + $bypassUrl = "$BaseUrl$pattern" + $response = Invoke-SafeWebRequest -Uri $bypassUrl + $response.StatusCode | Should -Be 200 + Write-Host "โœ… Bypass allowed: $pattern" -ForegroundColor Green + } +} + +<# +.SYNOPSIS + Measures response time for a given endpoint + +.PARAMETER Url + URL to test response time for + +.PARAMETER MaxResponseTimeMs + Maximum acceptable response time in milliseconds +#> +function Test-ResponseTime { + param( + [Parameter(Mandatory)] + [string]$Url, + [int]$MaxResponseTimeMs = 5000 + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $response = Invoke-SafeWebRequest -Uri $Url + $stopwatch.Stop() + + $response.StatusCode | Should -Be 200 + $stopwatch.ElapsedMilliseconds | Should -BeLessThan $MaxResponseTimeMs + + Write-Host "Response time: $($stopwatch.ElapsedMilliseconds)ms" -ForegroundColor Cyan + return $stopwatch.ElapsedMilliseconds +} + +<# +.SYNOPSIS + Tests concurrent request handling + +.PARAMETER Url + URL to test with concurrent requests + +.PARAMETER RequestCount + Number of concurrent requests to make + +.PARAMETER MinSuccessCount + Minimum number of requests that should succeed +#> +function Test-ConcurrentRequests { + param( + [Parameter(Mandatory)] + [string]$Url, + [int]$RequestCount = 5, + [int]$MinSuccessCount = 3 + ) + + $jobs = @() + 1..$RequestCount | ForEach-Object { + $jobs += Start-Job -ScriptBlock { + param($TestUrl) + try { + $response = Invoke-WebRequest -Uri $TestUrl -UseBasicParsing -TimeoutSec 10 + return @{ StatusCode = $response.StatusCode; Success = $true } + } + catch { + return @{ StatusCode = 0; Success = $false; Error = $_.Exception.Message } + } + } -ArgumentList $Url + } + + $results = $jobs | Wait-Job | Receive-Job + $jobs | Remove-Job + + $successfulRequests = ($results | Where-Object { $_.Success }).Count + $successfulRequests | Should -BeGreaterOrEqual $MinSuccessCount + + Write-Host "Successful concurrent requests: $successfulRequests/$RequestCount" -ForegroundColor Cyan + return $successfulRequests +} + +# Helper functions are available when dot-sourced +# No Export-ModuleMember needed for dot-sourcing diff --git a/scripts/integration-tests.Tests.ps1 b/scripts/integration-tests.Tests.ps1 new file mode 100644 index 0000000..d3ad7f8 --- /dev/null +++ b/scripts/integration-tests.Tests.ps1 @@ -0,0 +1,434 @@ +BeforeAll { + # Import test helper functions + . "$PSScriptRoot/TestHelpers.ps1" + + # Test configuration + $script:BaseUrl = "http://localhost:8000" + $script:TraefikApiUrl = "http://localhost:8080" + + # Ensure all services are ready before running tests + $services = @( + @{ Url = "$TraefikApiUrl/api/rawdata"; Name = "Traefik API" }, + @{ Url = "$BaseUrl/bypass"; Name = "Bypass service" }, + @{ Url = "$BaseUrl/protected"; Name = "Protected service" }, + @{ Url = "$BaseUrl/remediation-test"; Name = "Remediation test service" }, + @{ Url = "$BaseUrl/error-test"; Name = "Error test service" } + ) + + Wait-ForAllServices -Services $services +} + +Describe "ModSecurity Plugin Basic Functionality" { + Context "Service Availability" { + It "Should have Traefik API accessible" { + $response = Invoke-SafeWebRequest -Uri "$TraefikApiUrl/api/rawdata" + $response.StatusCode | Should -Be 200 + } + + It "Should have bypass service accessible" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/bypass" + $response.StatusCode | Should -Be 200 + $response.Content | Should -Match "Hostname" + } + + It "Should have protected service accessible with valid requests" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected" + $response.StatusCode | Should -Be 200 + $response.Content | Should -Match "Hostname" + } + } +} + +Describe "WAF Protection Tests" { + Context "Malicious Request Detection" { + It "Should block common attack patterns" { + $maliciousPatterns = @( + "?id=1' OR '1'='1", # SQL injection + "?search=", # XSS + "?file=../../../etc/passwd", # Path traversal + "?cmd=; ls -la" # Command injection + ) + + Test-MaliciousPatterns -BaseUrl "$BaseUrl/protected" -Patterns $maliciousPatterns + } + } + + Context "Legitimate Request Handling" { + It "Should allow normal GET requests" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected/normal-path" + $response.StatusCode | Should -Be 200 + } + + It "Should allow POST requests with normal data" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected" -Method POST -Body "name=john&email=john@example.com" + $response.StatusCode | Should -Be 200 + } + + It "Should allow requests with normal query parameters" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected?page=1&limit=10&sort=name" + $response.StatusCode | Should -Be 200 + } + } +} + +Describe "Remediation Response Header Tests" { + Context "Custom Header Configuration" { + It "Should add remediation header when request is blocked" { + $statusCode = Test-WafBlocking -Url "$BaseUrl/protected?id=1' OR '1'='1" + $statusCode | Should -BeGreaterOrEqual 400 + } + + It "Should not add remediation header for legitimate requests" { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected" + $response.Headers["X-Waf-Status"] | Should -BeNullOrEmpty + } + } + + Context "Remediation Header Logging" { + It "Should log remediation header as request header in access logs for blocked requests" { + # Make a blocked request to the remediation test endpoint + $maliciousUrl = "$BaseUrl/remediation-test?id=1' OR '1'='1" + try { + $response = Invoke-SafeWebRequest -Uri $maliciousUrl + $response.StatusCode | Should -BeGreaterOrEqual 400 + } catch { + # Expected for blocked requests - check if it's a 403/blocked response + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + $statusCode | Should -BeGreaterOrEqual 400 + } else { + throw "Unexpected error: $($_.Exception.Message)" + } + } + + # Wait a moment for log to be written + Start-Sleep -Seconds 2 + + # Read the access.log file from the traefik container + $accessLogContent = docker exec traefik-modsecurity-plugin-traefik-1 cat /var/log/traefik/access.log 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to read traefik access log" + } + + # Parse the log lines and check for any entries related to the remediation test + $logLines = $accessLogContent -split "`n" | Where-Object { $_.Trim() -ne "" } + + # Validate that ALL log lines are properly formatted JSON (no malformed lines should exist) + $allLogEntries = @() + foreach ($line in $logLines) { + try { + $logEntry = $line | ConvertFrom-Json + $allLogEntries += $logEntry + } catch { + throw "Malformed JSON line found in log file: '$line'." + } + } + + # Look for log entries where the X-Waf-Status request header is present for blocked requests + $remediationHeaderLogFound = ($allLogEntries | Where-Object { + $_.'request_X-Waf-Status' -and + $_.RequestPath -like "/remediation-test*" + }).Count -gt 0 + + # Verify that the remediation header was added to the request + $remediationHeaderLogFound | Should -Be $true + } + + It "Should NOT log remediation header as request header for allowed requests" { + # Make an allowed request to the remediation test endpoint + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/remediation-test" + $response.StatusCode | Should -Be 200 + + # Wait a moment for any potential log to be written + Start-Sleep -Seconds 2 + + # Read the access.log file from the traefik container + $accessLogContent = docker exec traefik-modsecurity-plugin-traefik-1 cat /var/log/traefik/access.log 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to read traefik access log" + } + + # Parse the log lines and check for any entries related to the remediation test + $logLines = $accessLogContent -split "`n" | Where-Object { $_.Trim() -ne "" } + + # Validate that ALL log lines are properly formatted JSON (no malformed lines should exist) + $allLogEntries = @() + foreach ($line in $logLines) { + try { + $logEntry = $line | ConvertFrom-Json + $allLogEntries += $logEntry + } catch { + throw "Malformed JSON line found in log file: '$line'." + } + } + + # Look for any request headers in successful requests to remediation-test + # Exclude requests that have error or unhealthy headers (these are not "allowed" requests) + $remediationHeaderInAllowedRequest = ($allLogEntries | Where-Object { + $_.'request_X-Waf-Status' -and + $_.RequestPath -eq "/remediation-test" -and + $_.DownstreamStatus -eq 200 -and + $_.'request_X-Waf-Status' -ne "error" -and + $_.'request_X-Waf-Status' -ne "unhealthy" + }).Count -gt 0 + + # Verify that remediation header is NOT added to allowed requests + $remediationHeaderInAllowedRequest | Should -Be $false + } + + It "Should log 'unhealthy' header when ModSecurity backend is unavailable" { + # Stop the ModSecurity WAF container to simulate unhealthy state + docker stop traefik-modsecurity-plugin-waf-1 + + # Wait a moment for the container to stop + Start-Sleep -Seconds 3 + + # Make multiple requests to trigger the unhealthy state + # The first request will fail and mark WAF as unhealthy + # The second request should use the unhealthy path + try { + $response1 = Invoke-SafeWebRequest -Uri "$BaseUrl/remediation-test" -TimeoutSec 5 + # If first request succeeds, WAF might not be marked unhealthy yet + } catch { + # Expected for first request when WAF is down + } + + # Wait for WAF to be marked as unhealthy + Start-Sleep -Seconds 2 + + # Make another request - this should use the unhealthy path + try { + $response2 = Invoke-SafeWebRequest -Uri "$BaseUrl/remediation-test" -TimeoutSec 5 + $response2.StatusCode | Should -Be 200 + } catch { + # If still failing, that's also acceptable - check if it's a 502/503 response + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + $statusCode | Should -BeGreaterOrEqual 500 + } else { + throw "Unexpected error: $($_.Exception.Message)" + } + } + + # Wait a moment for log to be written + Start-Sleep -Seconds 2 + + # Read the access.log file from the traefik container + $accessLogContent = docker exec traefik-modsecurity-plugin-traefik-1 cat /var/log/traefik/access.log 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to read traefik access log" + } + + # Parse the log lines + $logLines = $accessLogContent -split "`n" | Where-Object { $_.Trim() -ne "" } + + # Validate that ALL log lines are properly formatted JSON + $allLogEntries = @() + foreach ($line in $logLines) { + try { + $logEntry = $line | ConvertFrom-Json + $allLogEntries += $logEntry + } catch { + throw "Malformed JSON line found in log file: '$line'." + } + } + + # Look for log entries with 'unhealthy' header value + $unhealthyHeaderFound = ($allLogEntries | Where-Object { + $_.'request_X-Waf-Status' -eq "unhealthy" -and + $_.RequestPath -like "/remediation-test*" + }).Count -gt 0 + + # Verify that the unhealthy header was logged + $unhealthyHeaderFound | Should -Be $true + + # Restart the WAF container for other tests + docker start traefik-modsecurity-plugin-waf-1 + Start-Sleep -Seconds 5 + } + + It "Should log 'error' header when ModSecurity communication fails" { + # Make a request to the error test service (with invalid ModSecurity URL) + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/error-test" + $response.StatusCode | Should -Be 200 + + # Wait a moment for log to be written + Start-Sleep -Seconds 2 + + # Read the access.log file from the traefik container + $accessLogContent = docker exec traefik-modsecurity-plugin-traefik-1 cat /var/log/traefik/access.log 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to read traefik access log" + } + + # Parse the log lines + $logLines = $accessLogContent -split "`n" | Where-Object { $_.Trim() -ne "" } + + # Validate that ALL log lines are properly formatted JSON + $allLogEntries = @() + foreach ($line in $logLines) { + try { + $logEntry = $line | ConvertFrom-Json + $allLogEntries += $logEntry + } catch { + throw "Malformed JSON line found in log file: '$line'." + } + } + + # Look for log entries with 'error' header value + $errorHeaderFound = ($allLogEntries | Where-Object { + $_.'request_X-Waf-Status' -eq "error" -and + $_.RequestPath -like "/error-test*" + }).Count -gt 0 + + # Verify that the error header was logged + $errorHeaderFound | Should -Be $true + } + } +} + +Describe "Bypass Functionality Tests" { + Context "WAF Bypass Verification" { + It "Should allow potentially malicious requests through bypass endpoint" { + $maliciousPatterns = @( + "?id=1' OR '1'='1", + "?search=", + "?file=../../../etc/passwd" + ) + + Test-BypassPatterns -BaseUrl "$BaseUrl/bypass" -Patterns $maliciousPatterns + } + } +} + +Describe "Performance and Health Tests" { + Context "Response Time Tests" { + It "Should respond within acceptable time limits" { + Test-ResponseTime -Url "$BaseUrl/protected" -MaxResponseTimeMs 5000 + } + + It "Should handle concurrent requests" { + Test-ConcurrentRequests -Url "$BaseUrl/protected" -RequestCount 5 -MinSuccessCount 3 + } + } + + Context "WAF Health Monitoring" { + # Removed health endpoint test - keeping it simple + } +} + +Describe "Performance Comparison Tests" { + Context "WAF vs Bypass Performance Analysis" { + It "Should measure performance difference between WAF-protected and bypass requests" { + $testIterations = 20 + $wafResponseTimes = @() + $bypassResponseTimes = @() + + Write-Host "๐Ÿ”„ Running performance comparison test with $testIterations iterations..." + + # Test WAF-protected endpoint + Write-Host "๐Ÿ“Š Testing WAF-protected endpoint..." + for ($i = 1; $i -le $testIterations; $i++) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected" -TimeoutSec 10 + $stopwatch.Stop() + if ($response.StatusCode -eq 200) { + $wafResponseTimes += $stopwatch.ElapsedMilliseconds + } + } catch { + $stopwatch.Stop() + Write-Warning "WAF request $i failed: $($_.Exception.Message)" + } + Start-Sleep -Milliseconds 50 # Small delay between requests + } + + # Test bypass endpoint + Write-Host "๐Ÿ“Š Testing bypass endpoint..." + for ($i = 1; $i -le $testIterations; $i++) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try { + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/bypass" -TimeoutSec 10 + $stopwatch.Stop() + if ($response.StatusCode -eq 200) { + $bypassResponseTimes += $stopwatch.ElapsedMilliseconds + } + } catch { + $stopwatch.Stop() + Write-Warning "Bypass request $i failed: $($_.Exception.Message)" + } + Start-Sleep -Milliseconds 50 # Small delay between requests + } + + # Calculate statistics + if ($wafResponseTimes.Count -gt 0 -and $bypassResponseTimes.Count -gt 0) { + $wafAvg = ($wafResponseTimes | Measure-Object -Average).Average + $wafMin = ($wafResponseTimes | Measure-Object -Minimum).Minimum + $wafMax = ($wafResponseTimes | Measure-Object -Maximum).Maximum + + $bypassAvg = ($bypassResponseTimes | Measure-Object -Average).Average + $bypassMin = ($bypassResponseTimes | Measure-Object -Minimum).Minimum + $bypassMax = ($bypassResponseTimes | Measure-Object -Maximum).Maximum + + $overhead = $wafAvg - $bypassAvg + + # Display results + Write-Host "`n๐Ÿ“ˆ Performance Comparison Results:" + Write-Host "โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”" + Write-Host "โ”‚ Endpoint โ”‚ Average (ms)โ”‚ Min (ms) โ”‚ Max (ms) โ”‚" + Write-Host "โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค" + Write-Host "โ”‚ WAF Protected โ”‚ $($wafAvg.ToString('F1').PadLeft(11)) โ”‚ $($wafMin.ToString('F1').PadLeft(11)) โ”‚ $($wafMax.ToString('F1').PadLeft(11)) โ”‚" + Write-Host "โ”‚ Bypass โ”‚ $($bypassAvg.ToString('F1').PadLeft(11)) โ”‚ $($bypassMin.ToString('F1').PadLeft(11)) โ”‚ $($bypassMax.ToString('F1').PadLeft(11)) โ”‚" + Write-Host "โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜" + Write-Host "`nโšก WAF Overhead: $($overhead.ToString('F1')) ms" + + # Store results for validation + $script:PerformanceResults = @{ + WafAverage = $wafAvg + BypassAverage = $bypassAvg + Overhead = $overhead + WafSamples = $wafResponseTimes.Count + BypassSamples = $bypassResponseTimes.Count + } + + # Validate that we have enough samples + $wafResponseTimes.Count | Should -BeGreaterOrEqual 15 -Because "We need at least 15 successful WAF requests for reliable measurement" + $bypassResponseTimes.Count | Should -BeGreaterOrEqual 15 -Because "We need at least 15 successful bypass requests for reliable measurement" + + # Validate that WAF adds some overhead (but not too much) + $overhead | Should -BeGreaterOrEqual 0 -Because "WAF should add some processing overhead" + $overhead | Should -BeLessThan 1000 -Because "WAF overhead should be reasonable (less than 1000ms)" + + } else { + throw "Insufficient successful requests for performance comparison" + } + } + } +} + +Describe "Error Handling and Edge Cases" { + Context "Large Request Handling" { + It "Should handle moderately large POST requests" { + $largeData = "data=" + ("a" * 1000) # 1KB of data + $response = Invoke-SafeWebRequest -Uri "$BaseUrl/protected" -Method POST -Body $largeData + $response.StatusCode | Should -Be 200 + } + } + + Context "Special Characters and Encoding" { + It "Should handle URL-encoded requests properly" { + $encodedUrl = "$BaseUrl/protected?name=" + [System.Web.HttpUtility]::UrlEncode("John & Jane") + $response = Invoke-SafeWebRequest -Uri $encodedUrl + $response.StatusCode | Should -Be 200 + } + } +} + +AfterAll { + Write-Host "`n๐Ÿ Integration tests completed!" -ForegroundColor Green + Write-Host "๐Ÿ“Š Test Results Summary:" -ForegroundColor Cyan + Write-Host " - Services tested: Traefik, ModSecurity WAF, Protected & Bypass endpoints" -ForegroundColor Gray + Write-Host " - Security features: SQL injection, XSS, Path traversal, Command injection protection" -ForegroundColor Gray + Write-Host " - Performance: Response time and concurrent request handling" -ForegroundColor Gray + Write-Host " - Custom features: Remediation headers, WAF bypass verification" -ForegroundColor Gray +}