From 98cd9f20bfa7fd934702c959fb45ef73b9b83b23 Mon Sep 17 00:00:00 2001 From: deivid Date: Thu, 13 Feb 2025 14:15:27 +0100 Subject: [PATCH 01/11] progress --- README.md | 1 + docker-compose.local.yml | 6 +++--- modsecurity.go | 31 ++++++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7ebcbfa..55a670b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ This plugin supports these configuration: * `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 +* `unhealthyWafBackOffPeriodSecs` (optional) the period, in seconds, to back off if calls to modsecurity fail. Default to 0. Default behaviour is to send a 502 Bad Gateway when there are problems communicating with crowdsec. ## Local development (docker-compose.local.yml) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 89dfee8..4997bf5 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,8 +4,8 @@ services: traefik: image: "traefik:v2.11.4" ports: - - "80:80" - - "8080:8080" + - "81:80" + - "8081:8080" command: - "--log.level=DEBUG" - "--accesslog=true" @@ -24,7 +24,7 @@ services: - 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/modsecurity.go b/modsecurity.go index b9dfff8..c4f23c2 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -22,7 +22,8 @@ type Config struct { 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 + 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 } // CreateConfig creates the default plugin configuration. @@ -33,6 +34,7 @@ func CreateConfig() *Config { BadRequestsThresholdCount: 25, BadRequestsThresholdPeriodSecs: 600, JailTimeDurationSecs: 600, + UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) } } @@ -46,6 +48,9 @@ type Modsecurity struct { jailEnabled bool badRequestsThresholdCount int badRequestsThresholdPeriodSecs int + unhealthyWafBackOffPeriodSecs int + unhealthyWaf bool // If the WAF is unhealthy + unhealthyWafMutex sync.Mutex jailTimeDurationSecs int jail map[string][]time.Time jailRelease map[string]time.Time @@ -100,6 +105,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h jailTimeDurationSecs: config.JailTimeDurationSecs, jail: make(map[string][]time.Time), jailRelease: make(map[string]time.Time), + unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, }, nil } @@ -123,6 +129,12 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { a.jailMutex.RUnlock() } + // If the WAF is unhealthy just forward the request early + if a.unhealthyWaf { + a.next.ServeHTTP(rw, req) + return + } + // Buffer the body if we want to read it here and send it in the request. body, err := io.ReadAll(req.Body) if err != nil { @@ -150,6 +162,23 @@ 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 == false { + a.logger.Printf("marking modsec as unhealthy for %ds fail to send HTTP request to modsec: %s", a.unhealthyWafBackOffPeriodSecs, err.Error()) + a.unhealthyWaf = true + 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 From 2bb09223835cad70ddf4d67a604c29fe9bdd0ca5 Mon Sep 17 00:00:00 2001 From: deivid Date: Thu, 13 Feb 2025 14:17:13 +0100 Subject: [PATCH 02/11] xx --- docker-compose.local.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 4997bf5..22d8cca 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,8 +4,8 @@ services: traefik: image: "traefik:v2.11.4" ports: - - "81:80" - - "8081:8080" + - "80:80" + - "8080:8080" command: - "--log.level=DEBUG" - "--accesslog=true" From a57af36ef0a74f4c32986dc5b451c580d79fa14a Mon Sep 17 00:00:00 2001 From: deivid Date: Thu, 13 Feb 2025 14:18:50 +0100 Subject: [PATCH 03/11] xx --- modsecurity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modsecurity.go b/modsecurity.go index c4f23c2..c491548 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -129,7 +129,7 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { a.jailMutex.RUnlock() } - // If the WAF is unhealthy just forward the request early + // If the WAF is unhealthy just forward the request early. No concurrency control here on purpose. if a.unhealthyWaf { a.next.ServeHTTP(rw, req) return From a8d2c8c76536067c7ce07fa423c26c113eed9c3f Mon Sep 17 00:00:00 2001 From: deivid Date: Thu, 13 Feb 2025 14:30:08 +0100 Subject: [PATCH 04/11] xx --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55a670b..05e9395 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ This plugin supports these configuration: * `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 -* `unhealthyWafBackOffPeriodSecs` (optional) the period, in seconds, to back off if calls to modsecurity fail. Default to 0. Default behaviour is to send a 502 Bad Gateway when there are problems communicating with crowdsec. +* `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. ## Local development (docker-compose.local.yml) From a2b1ebff96da4f0940c8455c135daa82997ab748 Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Wed, 3 Sep 2025 16:27:27 +0200 Subject: [PATCH 05/11] Cleanup jail --- README.md | 6 +-- modsecurity.go | 121 +++++++------------------------------------- modsecurity_test.go | 48 ++---------------- 3 files changed, 24 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 05e9395..e15230d 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 @@ -58,10 +58,6 @@ This plugin supports these 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 * `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. ## Local development (docker-compose.local.yml) diff --git a/modsecurity.go b/modsecurity.go index c491548..feb2c4a 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -17,44 +17,29 @@ import ( // Config the plugin configuration. 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 + TimeoutMillis int64 `json:"timeoutMillis,omitempty"` + ModSecurityUrl string `json:"modSecurityUrl,omitempty"` + UnhealthyWafBackOffPeriodSecs int `json:"unhealthyWafBackOffPeriodSecs,omitempty"` // If the WAF is unhealthy, back off } // CreateConfig creates the default plugin configuration. func CreateConfig() *Config { return &Config{ - TimeoutMillis: 2000, - JailEnabled: false, - BadRequestsThresholdCount: 25, - BadRequestsThresholdPeriodSecs: 600, - JailTimeDurationSecs: 600, - UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) + TimeoutMillis: 2000, + UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) } } // Modsecurity a Modsecurity plugin. type Modsecurity struct { - next http.Handler - modSecurityUrl string - name string - httpClient *http.Client - logger *log.Logger - jailEnabled bool - badRequestsThresholdCount int - badRequestsThresholdPeriodSecs int - unhealthyWafBackOffPeriodSecs int - unhealthyWaf bool // If the WAF is unhealthy - unhealthyWafMutex sync.Mutex - jailTimeDurationSecs int - jail map[string][]time.Time - jailRelease map[string]time.Time - jailMutex sync.RWMutex + next http.Handler + modSecurityUrl string + name string + httpClient *http.Client + logger *log.Logger + unhealthyWafBackOffPeriodSecs int + unhealthyWaf bool // If the WAF is unhealthy + unhealthyWafMutex sync.Mutex } // New creates a new Modsecurity plugin with the given configuration. @@ -94,18 +79,12 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h } 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, + modSecurityUrl: config.ModSecurityUrl, + next: next, + name: name, + httpClient: &http.Client{Timeout: timeout, Transport: transport}, + logger: log.New(os.Stdout, "", log.LstdFlags), + unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, }, nil } @@ -115,20 +94,6 @@ 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 - } - a.jailMutex.RUnlock() - } - // If the WAF is unhealthy just forward the request early. No concurrency control here on purpose. if a.unhealthyWaf { a.next.ServeHTTP(rw, req) @@ -186,9 +151,6 @@ 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) - } forwardResponse(resp, rw) return } @@ -217,48 +179,3 @@ func forwardResponse(resp *http.Response, rw http.ResponseWriter) { // 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..78555ba 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) { @@ -36,8 +37,6 @@ func TestModsecurity_ServeHTTP(t *testing.T) { serviceResponse response expectBody string expectStatus int - jailEnabled bool - jailConfig *Config }{ { name: "Forward request when WAF found no threats", @@ -49,7 +48,6 @@ func TestModsecurity_ServeHTTP(t *testing.T) { serviceResponse: serviceResponse, expectBody: "Response from service", expectStatus: 200, - jailEnabled: false, }, { name: "Intercepts request when WAF found threats", @@ -61,7 +59,6 @@ func TestModsecurity_ServeHTTP(t *testing.T) { serviceResponse: serviceResponse, expectBody: "Response from waf", expectStatus: 403, - jailEnabled: false, }, { name: "Does not forward Websockets", @@ -80,25 +77,6 @@ func TestModsecurity_ServeHTTP(t *testing.T) { serviceResponse: serviceResponse, expectBody: "Response from service", expectStatus: 200, - jailEnabled: false, - }, - { - 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, - }, }, } @@ -126,17 +104,8 @@ 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, } middleware, err := New(context.Background(), httpServiceHandler, config, "modsecurity-middleware") @@ -145,15 +114,6 @@ 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) From a7351217926c0327c0aea13c65b51a075a496a1b Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Wed, 3 Sep 2025 16:35:06 +0200 Subject: [PATCH 06/11] Add RemediationResponseHeader --- README.md | 1 + modsecurity.go | 12 ++++- modsecurity_test.go | 119 ++++++++++++++++++++++++++++++-------------- 3 files changed, 93 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e15230d..8f1f0ba 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ This plugin supports these configuration: * `timeoutMillis`: (optional) timeout in milliseconds for the http client to talk with modsecurity container. (default 2 seconds) * `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. +* `remediationResponseHeader`: (optional) name of the header to add to the response when requests are blocked by ModSecurity. The header value will contain the HTTP status code returned by ModSecurity. Default is empty (no header added). ## Local development (docker-compose.local.yml) diff --git a/modsecurity.go b/modsecurity.go index feb2c4a..add8652 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -20,13 +20,15 @@ type Config struct { TimeoutMillis int64 `json:"timeoutMillis,omitempty"` ModSecurityUrl string `json:"modSecurityUrl,omitempty"` UnhealthyWafBackOffPeriodSecs int `json:"unhealthyWafBackOffPeriodSecs,omitempty"` // If the WAF is unhealthy, back off + RemediationResponseHeader string `json:"remediationResponseHeader,omitempty"` // Header name to add when request is blocked } // CreateConfig creates the default plugin configuration. func CreateConfig() *Config { return &Config{ TimeoutMillis: 2000, - UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) + UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) + RemediationResponseHeader: "", // Empty string means no header will be added } } @@ -40,6 +42,7 @@ type Modsecurity struct { unhealthyWafBackOffPeriodSecs int unhealthyWaf bool // If the WAF is unhealthy unhealthyWafMutex sync.Mutex + remediationResponseHeader string // Header name to add when request is blocked } // New creates a new Modsecurity plugin with the given configuration. @@ -85,6 +88,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h httpClient: &http.Client{Timeout: timeout, Transport: transport}, logger: log.New(os.Stdout, "", log.LstdFlags), unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, + remediationResponseHeader: config.RemediationResponseHeader, }, nil } @@ -129,7 +133,7 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if err != nil { if a.unhealthyWafBackOffPeriodSecs > 0 { a.unhealthyWafMutex.Lock() - if a.unhealthyWaf == false { + 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 time.AfterFunc(time.Duration(a.unhealthyWafBackOffPeriodSecs)*time.Second, func() { @@ -151,6 +155,10 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { defer resp.Body.Close() if resp.StatusCode >= 400 { + // Add remediation header if configured + if a.remediationResponseHeader != "" { + rw.Header().Set(a.remediationResponseHeader, fmt.Sprintf("%d", resp.StatusCode)) + } forwardResponse(resp, rw) return } diff --git a/modsecurity_test.go b/modsecurity_test.go index 78555ba..41fd7ab 100644 --- a/modsecurity_test.go +++ b/modsecurity_test.go @@ -31,52 +31,86 @@ func TestModsecurity_ServeHTTP(t *testing.T) { } tests := []struct { - name string - request *http.Request - wafResponse response - serviceResponse response - expectBody string - expectStatus int + name string + request *http.Request + wafResponse response + serviceResponse response + expectBody string + expectStatus int + remediationResponseHeader 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, + 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, + remediationResponseHeader: "", + 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, + 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, + remediationResponseHeader: "", + 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, + wafResponse: response{StatusCode: 200, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from service", + expectStatus: 200, + remediationResponseHeader: "", + expectHeader: "", + expectHeaderValue: "", + }, + { + 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, + remediationResponseHeader: "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, + remediationResponseHeader: "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, + remediationResponseHeader: "X-Remediation-Info", + expectHeader: "X-Remediation-Info", + expectHeaderValue: "406", }, } @@ -104,8 +138,9 @@ func TestModsecurity_ServeHTTP(t *testing.T) { }) config := &Config{ - TimeoutMillis: 2000, - ModSecurityUrl: modsecurityMockServer.URL, + TimeoutMillis: 2000, + ModSecurityUrl: modsecurityMockServer.URL, + RemediationResponseHeader: tt.remediationResponseHeader, } middleware, err := New(context.Background(), httpServiceHandler, config, "modsecurity-middleware") @@ -119,6 +154,16 @@ func TestModsecurity_ServeHTTP(t *testing.T) { body, _ := io.ReadAll(resp.Body) assert.Equal(t, tt.expectBody, string(body)) assert.Equal(t, tt.expectStatus, resp.StatusCode) + + // Check for expected remediation header + if tt.expectHeader != "" { + assert.Equal(t, tt.expectHeaderValue, resp.Header.Get(tt.expectHeader), "Expected remediation header with correct value") + } else { + // When no header is expected, ensure no remediation header was added + if tt.remediationResponseHeader != "" { + assert.Empty(t, resp.Header.Get(tt.remediationResponseHeader), "No remediation header should be present") + } + } }) } } From 8a50d938b110f108d46de60ba6ffa5f8a1ececde Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Thu, 4 Sep 2025 06:02:21 +0200 Subject: [PATCH 07/11] Add integration tests --- .github/workflows/integration-test.yml | 252 +++++++++++++++++++ Test-Integration.ps1 | 301 +++++++++++++++++++++++ docker-compose.local.yml | 1 - docker-compose.test.yml | 46 ++++ docker-compose.yml | 1 - scripts/TestHelpers.ps1 | 323 +++++++++++++++++++++++++ scripts/integration-tests.Tests.ps1 | 141 +++++++++++ 7 files changed, 1063 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/integration-test.yml create mode 100644 Test-Integration.ps1 create mode 100644 docker-compose.test.yml create mode 100644 scripts/TestHelpers.ps1 create mode 100644 scripts/integration-tests.Tests.ps1 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/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 22d8cca..533f559 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.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 - traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.unhealthyWafBackOffPeriodSecs=5 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..1706500 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,46 @@ +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" + - "--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" + + 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 + + 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" + - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080" + - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.timeoutMillis=3000" + - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.remediationResponseHeader=X-Waf-Status" + + # No WAF protection + whoami-bypass: + image: traefik/whoami + labels: + - "traefik.enable=true" + - "traefik.http.routers.bypass.rule=PathPrefix(`/bypass`)" \ 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/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..00491b2 --- /dev/null +++ b/scripts/integration-tests.Tests.ps1 @@ -0,0 +1,141 @@ +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" } + ) + + 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 + } + } +} + +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 "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 +} From eda2d911535800029c02b74f956b277982c14a03 Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Thu, 4 Sep 2025 08:34:01 +0200 Subject: [PATCH 08/11] minor fixes --- docker-compose.test.yml | 8 ++++---- modsecurity.go | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1706500..7422e14 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -33,10 +33,10 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.protected.rule=PathPrefix(`/protected`)" - - "traefik.http.routers.protected.middlewares=waf" - - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.modSecurityUrl=http://waf:8080" - - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.timeoutMillis=3000" - - "traefik.http.middlewares.waf.plugin.traefik-modsecurity-plugin.remediationResponseHeader=X-Waf-Status" + - "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.remediationResponseHeader=X-Waf-Status" # No WAF protection whoami-bypass: diff --git a/modsecurity.go b/modsecurity.go index add8652..3df12d4 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -113,8 +113,7 @@ 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 { @@ -176,11 +175,9 @@ 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) From 5f6733a8c906abba7cfb84f31ff06b5fb4a32f4e Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Thu, 4 Sep 2025 09:19:08 +0200 Subject: [PATCH 09/11] add extra configuration options --- README.md | 15 +++++++++++++-- modsecurity.go | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8f1f0ba..e81b7dd 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,23 @@ 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) +* `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. * `remediationResponseHeader`: (optional) name of the header to add to the response when requests are blocked by ModSecurity. 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) See [docker-compose.local.yml](docker-compose.local.yml) diff --git a/modsecurity.go b/modsecurity.go index 3df12d4..a3daca0 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -21,14 +21,22 @@ type Config struct { ModSecurityUrl string `json:"modSecurityUrl,omitempty"` UnhealthyWafBackOffPeriodSecs int `json:"unhealthyWafBackOffPeriodSecs,omitempty"` // If the WAF is unhealthy, back off RemediationResponseHeader string `json:"remediationResponseHeader,omitempty"` // Header name to add when request is blocked + 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, - UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) - RemediationResponseHeader: "", // Empty string means no header will be added + TimeoutMillis: 2000, // Original default: 2 seconds + UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) + RemediationResponseHeader: "", // 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) } } @@ -52,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 } @@ -66,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, @@ -81,6 +89,24 @@ 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, From b08ac87f63a092cde8b37997882f91727de52c74 Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Thu, 4 Sep 2025 10:11:49 +0200 Subject: [PATCH 10/11] Rename status header --- README.md | 2 +- docker-compose.test.yml | 43 +++++- modsecurity.go | 77 ++++++----- modsecurity_test.go | 138 +++++++++---------- scripts/integration-tests.Tests.ps1 | 206 +++++++++++++++++++++++++++- 5 files changed, 359 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index e81b7dd..f348167 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This plugin supports these 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 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. -* `remediationResponseHeader`: (optional) name of the header to add to the response when requests are blocked by ModSecurity. The header value will contain the HTTP status code returned by ModSecurity. Default is empty (no header added). +* `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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 7422e14..b9dbddd 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -8,12 +8,17 @@ services: - "--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 @@ -23,6 +28,8 @@ services: - 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 @@ -36,11 +43,43 @@ services: - "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.remediationResponseHeader=X-Waf-Status" + - "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`)" \ No newline at end of file + - "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/modsecurity.go b/modsecurity.go index a3daca0..9526313 100644 --- a/modsecurity.go +++ b/modsecurity.go @@ -17,40 +17,40 @@ import ( // Config the plugin configuration. type Config struct { - TimeoutMillis int64 `json:"timeoutMillis,omitempty"` - ModSecurityUrl string `json:"modSecurityUrl,omitempty"` - UnhealthyWafBackOffPeriodSecs int `json:"unhealthyWafBackOffPeriodSecs,omitempty"` // If the WAF is unhealthy, back off - RemediationResponseHeader string `json:"remediationResponseHeader,omitempty"` // Header name to add when request is blocked - 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) + TimeoutMillis int64 `json:"timeoutMillis,omitempty"` + ModSecurityUrl string `json:"modSecurityUrl,omitempty"` + 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, // Original default: 2 seconds - UnhealthyWafBackOffPeriodSecs: 0, // 0 to NOT backoff (original behaviour) - RemediationResponseHeader: "", // 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) + 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) } } // Modsecurity a Modsecurity plugin. type Modsecurity struct { - next http.Handler - modSecurityUrl string - name string - httpClient *http.Client - logger *log.Logger - unhealthyWafBackOffPeriodSecs int - unhealthyWaf bool // If the WAF is unhealthy - unhealthyWafMutex sync.Mutex - remediationResponseHeader string // Header name to add when request is blocked + next http.Handler + modSecurityUrl string + name string + httpClient *http.Client + logger *log.Logger + 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. @@ -108,13 +108,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h } return &Modsecurity{ - modSecurityUrl: config.ModSecurityUrl, - next: next, - name: name, - httpClient: &http.Client{Timeout: timeout, Transport: transport}, - logger: log.New(os.Stdout, "", log.LstdFlags), - unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, - remediationResponseHeader: config.RemediationResponseHeader, + modSecurityUrl: config.ModSecurityUrl, + next: next, + name: name, + httpClient: &http.Client{Timeout: timeout, Transport: transport}, + logger: log.New(os.Stdout, "", log.LstdFlags), + unhealthyWafBackOffPeriodSecs: config.UnhealthyWafBackOffPeriodSecs, + modSecurityStatusRequestHeader: config.ModSecurityStatusRequestHeader, }, nil } @@ -126,6 +126,9 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // 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.next.ServeHTTP(rw, req) return } @@ -143,6 +146,9 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 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 @@ -161,6 +167,9 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 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() @@ -180,9 +189,9 @@ func (a *Modsecurity) ServeHTTP(rw http.ResponseWriter, req *http.Request) { defer resp.Body.Close() if resp.StatusCode >= 400 { - // Add remediation header if configured - if a.remediationResponseHeader != "" { - rw.Header().Set(a.remediationResponseHeader, fmt.Sprintf("%d", resp.StatusCode)) + // 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 diff --git a/modsecurity_test.go b/modsecurity_test.go index 41fd7ab..432789c 100644 --- a/modsecurity_test.go +++ b/modsecurity_test.go @@ -31,37 +31,37 @@ func TestModsecurity_ServeHTTP(t *testing.T) { } tests := []struct { - name string - request *http.Request - wafResponse response - serviceResponse response - expectBody string - expectStatus int - remediationResponseHeader string - expectHeader string - expectHeaderValue string + 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, - remediationResponseHeader: "", - expectHeader: "", - expectHeaderValue: "", + 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, - remediationResponseHeader: "", - 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, + modSecurityStatusRequestHeader: "", + expectHeader: "", + expectHeaderValue: "", }, { name: "Does not forward Websockets", @@ -71,46 +71,46 @@ func TestModsecurity_ServeHTTP(t *testing.T) { Method: http.MethodGet, URL: req.URL, }, - wafResponse: response{StatusCode: 200, Body: "Response from waf"}, - serviceResponse: serviceResponse, - expectBody: "Response from service", - expectStatus: 200, - remediationResponseHeader: "", - expectHeader: "", - expectHeaderValue: "", + wafResponse: response{StatusCode: 200, Body: "Response from waf"}, + serviceResponse: serviceResponse, + expectBody: "Response from service", + expectStatus: 200, + modSecurityStatusRequestHeader: "", + expectHeader: "", + expectHeaderValue: "", }, { - 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, - remediationResponseHeader: "X-Waf-Block", - expectHeader: "X-Waf-Block", - expectHeaderValue: "403", + 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, - remediationResponseHeader: "X-Waf-Block", - expectHeader: "", - expectHeaderValue: "", + 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, - remediationResponseHeader: "X-Remediation-Info", - expectHeader: "X-Remediation-Info", - expectHeaderValue: "406", + 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", }, } @@ -138,9 +138,9 @@ func TestModsecurity_ServeHTTP(t *testing.T) { }) config := &Config{ - TimeoutMillis: 2000, - ModSecurityUrl: modsecurityMockServer.URL, - RemediationResponseHeader: tt.remediationResponseHeader, + TimeoutMillis: 2000, + ModSecurityUrl: modsecurityMockServer.URL, + ModSecurityStatusRequestHeader: tt.modSecurityStatusRequestHeader, } middleware, err := New(context.Background(), httpServiceHandler, config, "modsecurity-middleware") @@ -155,13 +155,13 @@ func TestModsecurity_ServeHTTP(t *testing.T) { assert.Equal(t, tt.expectBody, string(body)) assert.Equal(t, tt.expectStatus, resp.StatusCode) - // Check for expected remediation header + // Check for expected status header in request (not response) if tt.expectHeader != "" { - assert.Equal(t, tt.expectHeaderValue, resp.Header.Get(tt.expectHeader), "Expected remediation header with correct value") + 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 remediation header was added - if tt.remediationResponseHeader != "" { - assert.Empty(t, resp.Header.Get(tt.remediationResponseHeader), "No remediation header should be present") + // 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/integration-tests.Tests.ps1 b/scripts/integration-tests.Tests.ps1 index 00491b2..177b644 100644 --- a/scripts/integration-tests.Tests.ps1 +++ b/scripts/integration-tests.Tests.ps1 @@ -10,7 +10,9 @@ BeforeAll { $services = @( @{ Url = "$TraefikApiUrl/api/rawdata"; Name = "Traefik API" }, @{ Url = "$BaseUrl/bypass"; Name = "Bypass service" }, - @{ Url = "$BaseUrl/protected"; Name = "Protected 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 @@ -81,6 +83,208 @@ Describe "Remediation Response Header Tests" { $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" { From da48ab030378a168de10e7aebaaa2aaa3c679954 Mon Sep 17 00:00:00 2001 From: "deivid.garcia.garcia" Date: Thu, 4 Sep 2025 10:32:42 +0200 Subject: [PATCH 11/11] Performance test --- scripts/integration-tests.Tests.ps1 | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/scripts/integration-tests.Tests.ps1 b/scripts/integration-tests.Tests.ps1 index 177b644..d3ad7f8 100644 --- a/scripts/integration-tests.Tests.ps1 +++ b/scripts/integration-tests.Tests.ps1 @@ -317,6 +317,95 @@ Describe "Performance and Health Tests" { } } +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" {