e2e mock: add AppSec scenario + custom remediation header assertion

Address review feedback on the binary e2e suite:

- mocklapi: add an AppSec WAF stand-in (--appsec-addr) that blocks any URI
  containing "rpc2" — the exact probe from examples/appsec-enabled — and allows
  the rest. Lets the suite exercise the plugin's AppSec wiring (header
  forwarding + allow/block enforcement) without the real CRS engine.
- new scenarios/appsec: benign request passes, /foo/rpc2 is 403.
- custom-ban-page: assert the banned response carries the custom remediation
  header (remediationHeadersCustomName), per review.
- README: drop the "don't open issues / AppSec intentionally absent" framing;
  describe what the suite actually covers, including AppSec wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhx
2026-06-06 21:33:52 +02:00
co-authored by Claude Opus 4.8
parent 070a82992a
commit 8497f7de75
8 changed files with 98 additions and 19 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export GO111MODULE=on
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. # Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
# The local Docker suite (make e2e) lives in a separate PR/branch. # 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 default: lint test
+12 -12
View File
@@ -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 high-fidelity debugging against a real Crowdsec but is not exercised in CI; it
ships in its own PR (#333). 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 These tests validate the **plugin's own behaviour**: the request flow through
the Traefik middleware, the live / none / stream modes, caching, trusted-IP 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 The mock stands in for Crowdsec, emulating the slice of the LAPI HTTP contract
correctly — that is validated and owned by the upstream maintainer the plugin consumes — including a single, deterministic AppSec rule (block any
([@maxlerebourg](https://github.com/maxlerebourg)), not by this plugin. The URI containing `rpc2`, the probe from [`examples/appsec-enabled`](../../../examples/appsec-enabled)).
mock only emulates the slice of the LAPI HTTP contract the plugin consumes. 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 —
So please **don't open issues here about Crowdsec/AppSec detection accuracy** that lives upstream in Crowdsec.
based on this suite: the AppSec scenario is intentionally absent, and the mock
returns whatever decisions the test tells it to.
## What runs ## 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) | | 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/`) | | 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` | | 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 | | Backend | A plain HTTP responder built into the mock |
Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`, Fixed ports (override with env vars if needed): Traefik `8000`, LAPI `8090`,
backend `8091`. backend `8091`, AppSec `8092`.
## Running locally ## Running locally
@@ -62,7 +62,7 @@ mock/
traefik.yml # static Traefik config (shared by all scenarios) traefik.yml # static Traefik config (shared by all scenarios)
mocklapi/ mocklapi/
go.mod # nested module — kept out of the plugin's build/lint/vendor 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/ scenarios/
<name>/ <name>/
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend) dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
+8 -2
View File
@@ -4,7 +4,8 @@
# Unlike the Docker suite under tests/e2e/scenarios, this one runs Traefik as a # 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 # downloaded binary and replaces Crowdsec with a small HTTP mock (the mocklapi
# Go command). It validates the plugin's own behaviour (modes, cache, trusted # 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 # 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 # 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}" WEB_PORT="${WEB_PORT:-8000}"
LAPI_PORT="${LAPI_PORT:-8090}" LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}" BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -183,19 +185,23 @@ start_stack() {
sed \ sed \
-e "s|@@APIKEY@@|${LAPI_KEY}|g" \ -e "s|@@APIKEY@@|${LAPI_KEY}|g" \
-e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_PORT}|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|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \ -e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
"$mock_bin" \ "$mock_bin" \
--lapi-addr "127.0.0.1:${LAPI_PORT}" \ --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=$! MOCK_PID=$!
( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 & ( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
TRAEFIK_PID=$! TRAEFIK_PID=$!
wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30 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). # /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 wait_for_status "http://127.0.0.1:${WEB_PORT}/ping" 200 60
} }
+21 -4
View File
@@ -4,9 +4,10 @@
// test drive decisions through /admin instead of `cscli`. It also serves the // test drive decisions through /admin instead of `cscli`. It also serves the
// stub upstream that Traefik proxies allowed requests to. // stub upstream that Traefik proxies allowed requests to.
// //
// It is deliberately NOT a Crowdsec/AppSec conformance harness: Crowdsec's own // It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP
// correctness is the upstream maintainer's responsibility, not this plugin's. // CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a
// See the suite README. // 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 package main
import ( import (
@@ -14,6 +15,7 @@ import (
"flag" "flag"
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
) )
@@ -48,6 +50,8 @@ func main() {
// The stub upstream Traefik proxies allowed requests to — the binary-suite // The stub upstream Traefik proxies allowed requests to — the binary-suite
// equivalent of the traefik/whoami container. Not AppSec. // equivalent of the traefik/whoami container. Not AppSec.
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service") 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() flag.Parse()
go func() { 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() mux := http.NewServeMux()
// Readiness probe for the test harness (empty body, 200). // 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)) log.Fatal(http.ListenAndServe(*lapiAddr, mux))
} }
@@ -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"
+23
View File
@@ -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
@@ -25,3 +25,4 @@ http:
forwardedHeadersTrustedIps: forwardedHeadersTrustedIps:
- "127.0.0.1/32" - "127.0.0.1/32"
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html" banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
remediationHeadersCustomName: "X-E2E-Remediation"
@@ -19,6 +19,9 @@ body() {
echo "[$SCENARIO] banned response body contains the custom marker" 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" 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 run_scenario "$SCENARIO" "$HERE" body