mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 11:38:59 +02:00
✅ 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:
+12
-12
@@ -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/
|
||||
<name>/
|
||||
dynamic.yml # Traefik dynamic config (router + bouncer middleware + backend)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Executable
+23
@@ -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:
|
||||
- "127.0.0.1/32"
|
||||
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
|
||||
remediationHeadersCustomName: "X-E2E-Remediation"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user