Skip to content

add the bot detection feature to the crowdsec traefik bouncer - #1

Draft
sabban wants to merge 19 commits into
mainfrom
crowdsec/bot/detection
Draft

add the bot detection feature to the crowdsec traefik bouncer#1
sabban wants to merge 19 commits into
mainfrom
crowdsec/bot/detection

Conversation

@sabban

@sabban sabban commented Jun 23, 2026

Copy link
Copy Markdown
Owner

add the bot detection feature to the crowdsec traefik bouncer

This is an early PR and depends on the crowdsec feature in the PR crowdsecurity/crowdsec#4268 This is not meant to be merged now, but to gather feedbacks.

The bouncer now parses structured AppSec JSON responses:

  • action
  • http_status
  • user_body_content
  • user_cookies
  • user_headers

When AppSec returns an action other than allow, the plugin writes the provided status, headers, cookies, and body back to the client. This lets CrowdSec serve the challenge HTML, set the
__crowdsec_challenge cookie, and expose the browser-side challenge flow through Traefik.

Legacy behavior is preserved: an empty/non-structured AppSec 403 still falls back to the existing ban handling.

For challenge mode, the Traefik side does not need a new plugin option. It needs AppSec enabled, and the challenge protocol paths must be routed through the same CrowdSec middleware as the protected application route.

The challenge mode has to be enabled crowdsec side, though. Documentation is not published yet, but the WIP documentation is available at crowdsecurity/crowdsec-docs#1099 and crowdsecurity/crowdsec-docs#1100.

This PR is meant to gather feedback and not to be merged right now.

My test configuration docker file.

name: crowdsec-bouncer-traefik-plugin

  services:
    traefik:
      image: traefik:latest
      container_name: traefik
      restart: unless-stopped
      command:
        - --log.level=DEBUG
        - --accesslog
        - --accesslog.filepath=/var/log/traefik/access.log
        - --api.insecure=true
        - --providers.docker=true
        - --providers.docker.exposedbydefault=false
        - --entrypoints.web.address=:80
        - --experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
      depends_on:
        crowdsec:
          condition: service_started
          required: true
      environment:
        DOCKER_API_VERSION: "1.40"
      ports:
        - 8000:80
        - 8080:8080
      volumes:
        - /var/run/docker.sock:/var/run/docker.sock:ro
        - logs-local:/var/log/traefik
        - ./ban.html:/ban.html:ro
        - ./captcha.html:/captcha.html:ro
        - ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
    crowdsec:
      image: crowdsec:waf-challenge-pr4268
      container_name: crowdsec
      restart: unless-stopped
      environment:
        BOUNCER_KEY_TRAEFIK: 40796d93c2958f9e58345514e67740e5=
        COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
        CUSTOM_HOSTNAME: crowdsec
      labels:
        traefik.enable: "false"
      volumes:
        - ./acquis.challenge.yaml:/etc/crowdsec/acquis.yaml:ro
        - logs-local:/var/log/traefik:ro
        - crowdsec-db-pr4268:/var/lib/crowdsec/data
        - crowdsec-config-pr4268:/etc/crowdsec
        - ./local-challenge.yaml:/etc/crowdsec/appsec-configs/local-challenge.yaml:ro

    bar:
      image: traefik/whoami
      container_name: simple-service-bar
      restart: unless-stopped
      labels:
        traefik.enable: "true"
        traefik.http.services.service-bar.loadbalancer.server.port: "80"

        traefik.http.routers.bar.rule: PathPrefix(`/bar`)
        traefik.http.routers.bar.middlewares: crowdsec@docker

        traefik.http.routers.crowdsec-challenge.rule: PathPrefix(`/crowdsec-internal/challenge`)
        traefik.http.routers.crowdsec-challenge.middlewares: crowdsec@docker
        traefik.http.routers.crowdsec-challenge.service: service-bar

        traefik.http.middlewares.crowdsec.plugin.bouncer.enabled: "true"
        traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel: DEBUG
        traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds: "15"
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode: stream
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey: 40796d93c2958f9e58345514e67740e5=
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme: http
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapihost: crowdsec:8080
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled: "true"
        traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost: crowdsec:7422
        traefik.http.middlewares.crowdsec.plugin.bouncer.remediationheaderscustomname: X-Remediation
        traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs: 172.21.0.1/8

  volumes:
    logs-local:
    crowdsec-db-pr4268:
    crowdsec-config-pr4268:

@sabban
sabban marked this pull request as draft June 23, 2026 14:48
highpingblorg and others added 18 commits June 28, 2026 22:30
maxlerebourg#325)

* Add parameter to configure Ban Response Content-Type

* Add testing for new BanResponseContentType parameter

* Ensure there is a fallback to default Content-Type is user provided empty value

* Set Content-Type even if banTemplate is nil

* Add more edge cases for testing ban response Content-Type

* Add CR/LF validation for BanResponseContentType

* Add CaptchaResponseContentType to allow separate Content-Type configuration for captcha responses

* Add testing for new CaptchaResponseContentType

* Update README

* Split nil and CR/LF response Content-Type value validation into separate function

* Throw error instead of setting the default in case of empty parameter declaration

* Update testing accordingly

* ✨ remove HTML from var name, add tests and infer content type from filePath

* 🍱 fix lint ?

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint + naming

* 🍱 fix lint

* 🍱 fuck lint

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](actions/cache@v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Consider 502, 503 and 504 as unavaible for appsec

Fixes maxlerebourg#337

* ✨ add test and the function isReverseProxyError

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
…rebourg#332)

* 🐛 fix appsec silently 403-ing gRPC streams with unreadable body

A bidirectional gRPC stream is an HTTP/2 request with no Content-Length
whose body never reaches EOF. Since maxlerebourg#321 removed the ContentLength guard,
appsecQuery buffered it with io.ReadAll, which blocked until the request
timed out and was turned into a 403 (issue maxlerebourg#323). The backend was never
reached (OriginStatus:0).

Mirror the reference lua-cs-bouncer behaviour: detect an unreadable body
(ProtoMajor >= 2 && ContentLength < 0) and, instead of buffering it,
forward the request to Appsec with headers only. Add a new
CrowdsecAppsecDropUnreadableBody option (default false) that mirrors the
reference APPSEC_DROP_UNREADABLE_BODY: when true, such requests are
blocked outright instead of forwarded without their body.

Readable HTTP/1.1 bodies are still buffered and inspected, so the bypass
closed by maxlerebourg#321 stays closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🚨 appsec: satisfy linters (gocritic ifElseChain, misspell)

Rewrite the body-handling if/else chain in appsecQuery as a switch
(gocritic) and use US spelling "behavior" (misspell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔇 appsec: drop redundant unreadable-body debug log

Address review on maxlerebourg#332: the caller (handleNextServeHTTP) already logs the
returned error with the request IP, so the inner Debug line duplicated it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🍱 increase gocyclo

* 🍱 fix lint

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
…g#330)

* ⬆️ bump example image versions to latest stable

Align every docker-compose and Helm-values example on the same set:
- Traefik       v3.0.0 / v3.5.0 → v3.7.1
- Crowdsec      v1.6.1-2 / v1.6.8 / latest → v1.7.8
- Plugin pin    v1.3.0 / v1.4.5 / v1.5.0 → v1.6.0

No env / volume changes needed: every Crowdsec example already mounts
/var/lib/crowdsec/data, so the v1.7 strict volume check is already
satisfied (CROWDSEC_BYPASS_DB_VOLUME_CHECK is only required when
running without persistence, as in the E2E suite).

* 🤖 chore: adopt Renovate (weekly, self-hosted) to replace manual version bumps

Automate the kind of bump this PR did by hand. Renovate covers what Dependabot
could not reach here:

- example docker-compose image tags (traefik / crowdsec) — native manager;
- the plugin self-pin (`experimental.plugins.bouncer.version=` in compose args
  and `version:` in the Traefik Helm values) — customManager, github-tags;
- the Crowdsec/Traefik image tags in the Helm values (no `repository` key, so
  matched by file) — customManager, docker;
- the pinned Traefik binary in the e2e mock suite (`TRAEFIK_VERSION`) —
  customManager, github-releases;
- go.mod + GitHub Actions — native managers (this is why we drop Dependabot:
  running both would open duplicate PRs).

Runs weekly via a self-hosted workflow (Mondays 04:00 UTC, plus manual
dispatch). Needs a RENOVATE_TOKEN secret (documented in the workflow). Renovate
PRs trigger the existing e2e CI, so a Traefik/Crowdsec bump is validated to
actually boot and bounce before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔒 ci: pin Go to 1.22 (yaegi ceiling) and cap Renovate's Go updates

The plugin is interpreted by yaegi, bundled in Traefik. Even the latest Traefik
(v3.7.1) ships yaegi v0.16.1, which only supports Go 1.22 — so the plugin's real
ceiling is Go 1.22 on every current Traefik, regardless of the Go version
Traefik itself is built with.

- main.yml: build/test on Go 1.22 (was 1.23) so `go build`/`go test` reject
  newer stdlib early; yaegi v0.16.1 stays the required yaegi_test guard.
- renovate.json: cap the go.mod `go`/`toolchain` directive at `<1.23` so
  Renovate keeps deps/actions/toolchain current but never pushes the plugin
  past what yaegi supports. Raise the cap when Traefik ships a newer yaegi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 ci(renovate): stop ignoring examples/ and tests/ so custom managers run

config:recommended ships a default ignorePaths that excludes **/examples/**
and **/tests/** (and **/vendor/**). That silently disabled 4 of the 5 custom
managers and all example/helm coverage this PR adds — Renovate only saw the
root docker-compose, go.mod and workflows.

Override ignorePaths to keep only vendor/node_modules. Verified via
`renovate --platform=local --dry-run=full`: package files detected go from
7 -> 32, and every custom manager now extracts its dep (plugin self-pin
across 11 compose files, traefik/crowdsec helm tags, e2e TRAEFIK_VERSION pin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ⏪ chore: drop one-time example version bumps, let Renovate own them

The manual version catch-up across the example docker-compose files and the
Helm values is now redundant: Renovate (this PR) covers all of them via its
native docker-compose/docker managers plus the custom managers for the plugin
self-pin and the e2e Traefik binary. Reverting these files to keep the PR a
clean "adopt Renovate" change; Renovate will open the bump PRs itself.

Note: examples/tls-auth uses crowdsecurity/crowdsec:latest again — Renovate
does not pin rolling tags, so that one stays floating unless pinned separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📌 examples(tls-auth): pin crowdsec to v1.6.8 instead of :latest

Rolling `latest` tags aren't reproducible and Renovate can't manage them.
v1.6.8 is already the crowdsec tag used in the root docker-compose files and,
unlike the `-2` build-suffixed tags, Renovate bumps it (→ v1.7.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📌 examples: normalize crowdsec v1.6.1-2 -> v1.6.8 so Renovate can manage it

Renovate's docker versioning won't advance a build-suffixed tag (v1.6.1-2) to
a clean release (v1.7.8), so those 8 examples would have stayed frozen. v1.6.8
is already used in the root compose files and Renovate bumps it. Also updated
the captcha README snippet to match its compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🍱 group all + no dashboard + branch prefix

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>
…maxlerebourg#347)

The 2026-07-03 runs surfaced two config problems:

- The grouped "renovate/all" branch made Renovate fetch and hold the
  changelog of every upgrade instance separately (Traefik v3.0.0->v3.7.6
  was fetched ~10x, once per compose file), which blew the default 4GB
  V8 heap: "FATAL ERROR: ... JavaScript heap out of memory" (exit 134),
  killing the run after the branch was pushed but before the PR was
  opened. Disable changelog fetching (release notes were truncated in
  the grouped PR body anyway) and raise the Node heap to 8GB as a
  safety net.

- No gitAuthor was set, so commits were authored as Mend's
  renovate@whitesourcesoftware.com, which GitHub flags "Unverified"
  (Vigilant Mode) and Renovate warns about on every run. Use the token
  owner's noreply address instead.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
…#352)

* Do not consider body unreadable when it's http.NoBody

Fix maxlerebourg#351

* 🐛 appsec: only drop unreadable bodies for methods that carry one

Gate the crowdsecAppsecUnreadableBodyBlock drop on the request method,
mirroring the reference lua-cs-bouncer METHODS_WITH_BODY list
(POST/PUT/PATCH/DELETE). Browser GETs over HTTP/3 never carry a
Content-Length (quic-go always wraps the stream in a non-nil body with
ContentLength -1), so they were all wrongly dropped; they are now
forwarded to appsec headers-only, while gRPC streams (always POST) stay
droppable.

Fix maxlerebourg#351

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🍱 add test and remove useless comment

* 🍱 add test for GET with unreadable body

* 🍱 fix log

* 🍱 fix test

---------

Co-authored-by: mhx <mathieu@hanotaux.fr>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
* feat: Allow cache reading from replicas

* 🍱 fix logic

* ✨ add testing for redis with mock

* 🍱 fix permission

* 📝 test(e2e/redis): fix swapped IP→verdict comments

The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for
1.2.3.5, and the run.sh assertions match that. Both doc comments
described the opposite mapping; correct them to match the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ✅ test(e2e/redis): exercise read-from-replica path

The redis scenario only set redisCacheHost, so it validated the writer
but never the round-robin reader path this feature adds. Split the mock
into two roles: the primary (--redis-addr) now answers every GET with a
miss, while the replica (--redis-read-addr) serves the hardcoded
verdicts. The scenario points redisCacheReadHosts at the replica (twice,
to drive round-robin), so the banned-IP-blocked assertion only passes if
the plugin actually reads decisions from the replica rather than the
primary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 📝 docs: note replicas don't fall back to primary on outage

When RedisCacheReadHosts is set, reads are not retried against the
primary if the replicas are unreachable. Document that this, combined
with the default RedisCacheUnreachableBlock=true, means a replica outage
can block traffic while the primary is healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(cache): avoid nil-pointer panic on empty redis read

redisCache.get fell through to `switch err.Error()` when Get returned a
nil error with an empty value, panicking on the nil error. simpleredis
never returns that combination today (a miss yields RedisMiss), so it
was unreachable in practice — but the read path is safer treating an
empty, error-free read as a cache miss, which also guarantees err is
non-nil before err.Error() is called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🍱 add test for rotation

* 🐛 readd redis/run.sh

* 🐛 fix redis/run.sh

* 🐛 fix test label; remove log

* 🍱 add tests for roundRobin

* 🐛 fix test

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Co-authored-by: mhx <mathieu@hanotaux.fr>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ Renovate update version.go

* 🍱 renovate every day
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
Co-authored-by: Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>
…version (maxlerebourg#365)

* ✨ cicd: bump the version before tagging instead of after

The version reported to the Crowdsec LAPI lives in version.go, so it must
be correct in the very commit the tag points at. Every mechanism so far
updated it *after* the tag existed, which cannot work:

- release.yml ran on `release: published` and force-moved the tag. It also
  failed on all four of its runs and was removed in maxlerebourg#360.
- The Renovate customManager on version.go uses the github-tags datasource,
  so it can only propose vX once vX is already tagged. The bump always lands
  after the tag.

Result: v1.7.0 is tagged at a commit reading v1.6.0 (maxlerebourg#363), same shape as
the earlier maxlerebourg#322.

Replace both with a two-step flow that bumps first and tags last, so the
released source always matches its tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🍱 reduce loc + remove claude code comment

* 🍱 remove useless spellcheck disable

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants