diff --git a/Makefile b/Makefile index 648a034..2581487 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ export GO111MODULE=on # Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. # The local Docker suite (make e2e) lives in a separate PR/branch. -E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha +E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec default: lint test diff --git a/tests/e2e/mock/README.md b/tests/e2e/mock/README.md index f163536..86999e1 100644 --- a/tests/e2e/mock/README.md +++ b/tests/e2e/mock/README.md @@ -10,20 +10,19 @@ suite** (real Traefik + Crowdsec, under `tests/e2e/scenarios`) is kept for high-fidelity debugging against a real Crowdsec but is not exercised in CI; it ships in its own PR (#333). -## Scope — what this suite does and does NOT test +## Scope — what this suite tests These tests validate the **plugin's own behaviour**: the request flow through the Traefik middleware, the live / none / stream modes, caching, trusted-IP -bypass, and ban / captcha page rendering. +bypass, ban / captcha page rendering, and the AppSec request path (header +forwarding + enforcing the engine's allow/block verdict). -They deliberately **do not** test that Crowdsec or its AppSec engine work -correctly — that is validated and owned by the upstream maintainer -([@maxlerebourg](https://github.com/maxlerebourg)), not by this plugin. The -mock only emulates the slice of the LAPI HTTP contract the plugin consumes. - -So please **don't open issues here about Crowdsec/AppSec detection accuracy** -based on this suite: the AppSec scenario is intentionally absent, and the mock -returns whatever decisions the test tells it to. +The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract +the plugin consumes — including a single, deterministic AppSec rule (block any +URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)). +It is not the real WAF engine, so this suite exercises the plugin's AppSec +*wiring* rather than the detection accuracy of OWASP CRS / virtual patching — +that lives upstream in Crowdsec. ## What runs @@ -32,10 +31,11 @@ returns whatever decisions the test tells it to. | Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) | | Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) | | LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli` | +| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest | | Backend | A plain HTTP responder built into the mock | Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`, -backend `8091`. +backend `8091`, AppSec `8092`. ## Running locally @@ -62,7 +62,7 @@ mock/ traefik.yml # static Traefik config (shared by all scenarios) mocklapi/ go.mod # nested module — kept out of the plugin's build/lint/vendor - main.go # mock LAPI + backend + main.go # mock LAPI + AppSec stand-in + backend scenarios/ / dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend) diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh index 499a76b..1431bd1 100644 --- a/tests/e2e/mock/lib/common.sh +++ b/tests/e2e/mock/lib/common.sh @@ -4,7 +4,8 @@ # Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a # downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi # Go command). It validates the plugin's own behaviour (modes, cache, trusted -# IPs, ban / captcha rendering), NOT Crowdsec or AppSec correctness. +# IPs, ban / captcha rendering, AppSec wiring) — not the accuracy of Crowdsec's +# detection or its WAF engine, which the mock only stands in for. # # Dependencies: bash, curl, go, tar. The Traefik binary is downloaded and the # mock is compiled into .cache/ on first use. That cache persists across local @@ -18,6 +19,7 @@ TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}" WEB_PORT="${WEB_PORT:-8000}" LAPI_PORT="${LAPI_PORT:-8090}" BACKEND_PORT="${BACKEND_PORT:-8091}" +APPSEC_PORT="${APPSEC_PORT:-8092}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -183,19 +185,23 @@ start_stack() { sed \ -e "s|@@APIKEY@@|${LAPI_KEY}|g" \ -e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|g" \ + -e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \ -e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \ -e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \ "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" "$mock_bin" \ --lapi-addr "127.0.0.1:${LAPI_PORT}" \ - --backend-addr "127.0.0.1:${BACKEND_PORT}" >"$WORKDIR/mock.log" 2>&1 & + --backend-addr "127.0.0.1:${BACKEND_PORT}" \ + --appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$WORKDIR/mock.log" 2>&1 & MOCK_PID=$! ( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 & TRAEFIK_PID=$! wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30 + # AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow). + wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30 # /ping is served by Traefik itself once it is up (plugin compilation included). wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60 } diff --git a/tests/e2e/mock/mocklapi/main.go b/tests/e2e/mock/mocklapi/main.go index 1d346f3..32a3631 100644 --- a/tests/e2e/mock/mocklapi/main.go +++ b/tests/e2e/mock/mocklapi/main.go @@ -4,9 +4,10 @@ // test drive decisions through /admin instead of `cscli`. It also serves the // stub upstream that Traefik proxies allowed requests to. // -// It is deliberately NOT a Crowdsec/AppSec conformance harness: Crowdsec's own -// correctness is the upstream maintainer's responsibility, not this plugin's. -// See the suite README. +// It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP +// CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a +// single deterministic rule so the suite can exercise the plugin's AppSec +// wiring (header forwarding, allow/block handling) end to end. See the README. package main import ( @@ -14,6 +15,7 @@ import ( "flag" "log" "net/http" + "strings" "sync" ) @@ -48,6 +50,8 @@ func main() { // The stub upstream Traefik proxies allowed requests to — the binary-suite // equivalent of the traefik/whoami container. Not AppSec. backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service") + // AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine. + appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock") flag.Parse() go func() { @@ -56,6 +60,19 @@ func main() { }))) }() + // AppSec mock: the plugin forwards the request metadata in X-Crowdsec-Appsec-* + // headers and reads our status — 200 allows, 403 blocks. We emulate one + // deterministic virtual-patching rule (block any URI containing "rpc2", the + // exact probe from examples/appsec-enabled) so the plugin's AppSec path is + // exercised without standing up the real WAF. + go func() { + log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") { + w.WriteHeader(http.StatusForbidden) + } + }))) + }() + mux := http.NewServeMux() // Readiness probe for the test harness (empty body, 200). @@ -113,6 +130,6 @@ func main() { } }) - log.Printf("mocklapi: LAPI on %s, backend on %s", *lapiAddr, *backendAddr) + log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr) log.Fatal(http.ListenAndServe(*lapiAddr, mux)) } diff --git a/tests/e2e/mock/scenarios/appsec/dynamic.yml b/tests/e2e/mock/scenarios/appsec/dynamic.yml new file mode 100644 index 0000000..05fa8d9 --- /dev/null +++ b/tests/e2e/mock/scenarios/appsec/dynamic.yml @@ -0,0 +1,29 @@ +http: + routers: + r: + rule: "PathPrefix(`/foo`)" + entryPoints: + - web + service: backend + middlewares: + - bouncer + services: + backend: + loadBalancer: + servers: + - url: "@@BACKEND_URL@@" + middlewares: + bouncer: + plugin: + bouncer: + enabled: "true" + # IP bouncing disabled — this scenario exercises AppSec only. + crowdsecMode: none + crowdsecLapiScheme: http + crowdsecLapiHost: "@@LAPI_HOST@@" + crowdsecLapiKey: "@@APIKEY@@" + crowdsecAppsecEnabled: "true" + crowdsecAppsecScheme: http + crowdsecAppsecHost: "@@APPSEC_HOST@@" + forwardedHeadersTrustedIps: + - "127.0.0.1/32" diff --git a/tests/e2e/mock/scenarios/appsec/run.sh b/tests/e2e/mock/scenarios/appsec/run.sh new file mode 100755 index 0000000..1e1d2bd --- /dev/null +++ b/tests/e2e/mock/scenarios/appsec/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=appsec + +# AppSec wiring check: the plugin forwards each request to the AppSec engine and +# enforces its verdict. The mock emulates one virtual-patching rule (block any +# URI containing "rpc2"), mirroring examples/appsec-enabled. This proves the +# plugin's AppSec path end to end (header forwarding + allow/block handling); it +# does not test the real WAF's detection accuracy. +body() { + echo "[$SCENARIO] benign request must pass (AppSec allows)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] request whose URI contains 'rpc2' must be blocked (AppSec 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo/rpc2" 403 -H "X-Forwarded-For: 1.2.3.4" +} + +run_scenario "$SCENARIO" "$HERE" body diff --git a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml index 6766b9f..2bfbdf6 100644 --- a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml +++ b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml @@ -25,3 +25,4 @@ http: forwardedHeadersTrustedIps: - "127.0.0.1/32" banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html" + remediationHeadersCustomName: "X-E2E-Remediation" diff --git a/tests/e2e/mock/scenarios/custom-ban-page/run.sh b/tests/e2e/mock/scenarios/custom-ban-page/run.sh index 7ef4fbf..c12b217 100755 --- a/tests/e2e/mock/scenarios/custom-ban-page/run.sh +++ b/tests/e2e/mock/scenarios/custom-ban-page/run.sh @@ -19,6 +19,9 @@ body() { echo "[$SCENARIO] banned response body contains the custom marker" assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)" + assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4" } run_scenario "$SCENARIO" "$HERE" body