mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 19:48: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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
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:
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user