diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2eef3dbc..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,24 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - # Maintain dependencies for Go - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" - - # Maintain dependencies for build tools - - package-ecosystem: "gomod" - directory: "/tools" - schedule: - interval: "weekly" - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 26514939..c7bff206 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,8 +15,13 @@ jobs: name: Main Process runs-on: ubuntu-latest env: - GO_VERSION: 1.23 + # Keep in sync with go.mod. Capped at 1.22 because the plugin is run by + # yaegi (bundled in Traefik) and even Traefik v3.7.1 ships yaegi v0.16.1, + # which only supports Go 1.22. Building on the floor makes go build / go + # test reject newer stdlib before yaegi_test does. + GO_VERSION: 1.22 GOLANGCI_LINT_VERSION: v1.63.4 + # yaegi_test guard — pin to the version current Traefik bundles. YAEGI_VERSION: v0.16.1 CGO_ENABLED: 0 defaults: @@ -40,7 +45,7 @@ jobs: # https://github.com/marketplace/actions/cache - name: Cache Go modules - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 00000000..79947c68 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,83 @@ +name: Release (1/2) Prepare + +# Step 1 of the release process: bump pluginVersion *before* the tag exists. +# +# The version reported to the Crowdsec LAPI lives in version.go, so it has to +# be correct in the very commit the tag points at. Anything that patches +# version.go after the release is published is too late: Traefik's plugin +# service caches the plugin archive per module+version, so users keep the +# source that was there when the tag was first resolved (see #322, #363). +# +# This workflow opens a "release" PR containing only that bump. Merging it +# triggers Release (2/2) Publish, which creates the tag and the GitHub release +# on the merged commit. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release, e.g. v1.7.1 or v1.8.0-alpha" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + name: Open release PR for ${{ inputs.version }} + runs-on: ubuntu-latest + steps: + - name: Check out main + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + + - name: Validate version + env: + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::'$VERSION' is not a vX.Y.Z / vX.Y.Z-suffix version" + exit 1 + fi + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "::error::tag $VERSION already exists" + exit 1 + fi + + - name: Bump version.go + env: + VERSION: ${{ inputs.version }} + run: | + sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"$VERSION"'"/' version.go + cat version.go + if git diff --quiet -- version.go; then + echo "::error::version.go already reads $VERSION, nothing to release" + exit 1 + fi + + - name: Push release branch and open PR + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git switch -c "release/$VERSION" + git commit -am "🔖 release $VERSION" + git push -u origin "release/$VERSION" + + cat > /tmp/pr-body.md < Keep the PR title as-is: **Release (2/2) Publish** matches on it. + EOF + + gh pr create --base main --head "release/$VERSION" --title "🔖 release $VERSION" --body-file /tmp/pr-body.md diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 00000000..7e213479 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,53 @@ +name: Release (2/2) Publish + +# Step 2 of the release process: tag and publish the commit prepared by +# Release (1/2) Prepare. +# +# Triggered by the release PR landing on main. The tag is created on that +# commit, so version.go inside the released source always matches the tag — +# no post-release patching, no force-moved tags. + +on: + push: + branches: [main] + paths: ["version.go"] + +permissions: + contents: write + +jobs: + publish: + name: Tag and publish + runs-on: ubuntu-latest + steps: + - name: Check out the pushed commit + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Resolve release version + id: resolve + run: | + version="$(git log -1 --format='%B' | grep -oP '🔖 release \Kv[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' || true)" + [ -z "$version" ] && { echo "version.go changed outside a release commit, nothing to do"; echo "release=false" >> "$GITHUB_OUTPUT"; exit 0; } + + in_source="$(sed -n 's/.*pluginVersion = "\([^"]*\)".*/\1/p' version.go)" + [ "$in_source" != "$version" ] && { echo "::error::commit says $version but version.go reads $in_source"; exit 1; } + git rev-parse -q --verify "refs/tags/$version" >/dev/null && { echo "::error::tag $version already exists"; exit 1; } + + echo "release=true" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "prerelease=$([[ "$version" == *-* ]] && echo '--prerelease')" >> "$GITHUB_OUTPUT" + + - name: Tag and create the GitHub release + if: steps.resolve.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.resolve.outputs.version }} + PRERELEASE: ${{ steps.resolve.outputs.prerelease }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "$VERSION" + git push origin "$VERSION" + gh release create "$VERSION" --title "$VERSION" --generate-notes $PRERELEASE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 5290557a..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Release Version Update - -on: - release: - types: [published] - -permissions: - contents: write - -jobs: - update-version: - name: Update version in source - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - ref: main - - - name: Extract version from tag - id: get_version - run: | - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - - name: Update version in version.go - run: | - sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go - cat version.go - - - name: Commit, push, and retag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add version.go - if git diff --cached --quiet; then - echo "Version already up to date, nothing to commit" - exit 0 - fi - git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}" - git push origin main - # Move the release tag to include the version update - git tag -f "${{ steps.get_version.outputs.tag }}" - git push -f origin "${{ steps.get_version.outputs.tag }}" diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 00000000..b2bdd808 --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,41 @@ +name: Renovate + +# Self-hosted Renovate: opens dependency-update PRs on a daily schedule. +# Config lives in /renovate.json. Requires a repo/org secret RENOVATE_TOKEN +# (a PAT with `repo` + `workflow` scope, or a fine-grained token with +# contents:write + pull-requests:write) so Renovate can push branches and open +# PRs. Trigger manually from the Actions tab via "Run workflow" to test. + +on: + schedule: + - cron: "0 4 * * *" # every day at 04:00 UTC + workflow_dispatch: + inputs: + logLevel: + description: "Renovate log level" + required: false + default: "info" + +permissions: + contents: read + +concurrency: + group: renovate + cancel-in-progress: false + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - name: Run Renovate + uses: renovatebot/github-action@v46.1.21 + with: + token: ${{ secrets.RENOVATE_TOKEN }} + env: + RENOVATE_REPOSITORIES: ${{ github.repository }} + RENOVATE_ONBOARDING: "false" + RENOVATE_REQUIRE_CONFIG: "required" + # The grouped "all" branch holds many upgrades; changelog/PR-body + # rendering for it blew the default 4GB V8 heap (exit 134 OOM). + NODE_OPTIONS: "--max-old-space-size=8192" + LOG_LEVEL: ${{ github.event.inputs.logLevel || 'info' }} diff --git a/.golangci.yml b/.golangci.yml index 2a5e6de0..33598d6e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,7 +7,7 @@ linters-settings: disable: - fieldalignment gocyclo: - min-complexity: 15 + min-complexity: 20 goconst: min-len: 5 min-occurrences: 4 @@ -41,6 +41,7 @@ linters-settings: - $test allow: - $gostd + - github.com/maxlerebourg/simpleredis - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip - github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration diff --git a/Makefile b/Makefile index 11a2f61c..5dcec897 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 appsec tls-system-ca +E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*)) default: lint test @@ -20,7 +20,7 @@ yaegi_test: e2e_mock: $(addprefix e2e_mock_,$(E2E_MOCK_SCENARIOS)) e2e_mock_%: - ./tests/e2e/mock/scenarios/$*/run.sh + bash ./tests/e2e/mock/scenarios/$*/run.sh vendor: go mod vendor @@ -124,4 +124,3 @@ show_metrics: show_decisions: docker exec crowdsec cscli decisions list - diff --git a/README.md b/README.md index 62c21ca4..6c36b292 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,10 @@ make run - int64 - default: 10485760 (= 10MB) - Transmit only the first number of bytes to Crowdsec Appsec Server. +- CrowdsecAppsecUnreadableBodyBlock + - bool + - default: true + - Behaviour when the request body cannot be buffered for inspection (HTTP/2 or HTTP/3 request without a `Content-Length`, typically a bidirectional gRPC stream). When `false` the request is forwarded to the Appsec Server with headers only (the body is left to stream through untouched). When `true` the request is blocked outright. Mirrors the reference bouncers' `APPSEC_DROP_UNREADABLE_BODY` option. - CrowdsecAppsecKey - string - default: value of `CrowdsecLapiKey` @@ -440,7 +444,12 @@ make run - RedisCacheHost - string - default: "redis:6379" - - hostname and port for the Redis service + - hostname and port for the Redis write host (primary) +- RedisCacheReadHosts + - []string + - default: [] + - List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty. + - Note: when set, reads are not retried against RedisCacheHost (the primary) if the replicas are unreachable. With RedisCacheUnreachableBlock at its default (true), a replica outage will therefore block/delay requests even though the primary is healthy. - RedisCachePassword - string - default: "" @@ -513,14 +522,14 @@ make run - int64 - default: 1800 (= 30 minutes) - Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid -- CaptchaHTMLFilePath +- CaptchaFilePath - string - default: /captcha.html - - Path where the captcha template is stored -- BanHTMLFilePath + - Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension. +- BanFilePath - string - default: "" - - Path where the ban html file is stored (default empty ""=disabled) + - Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension. - TraceHeadersCustomName - string - default: "" @@ -616,6 +625,7 @@ http: crowdsecAppsecFailureBlock: true crowdsecAppsecUnreachableBlock: true crowdsecAppsecBodyLimit: 10485760 + crowdsecAppsecUnreadableBodyBlock: false crowdsecLapiKey: privateKey-foo crowdsecLapiScheme: http crowdsecLapiHost: crowdsec:8080 @@ -635,7 +645,10 @@ http: forwardedHeadersCustomName: X-Custom-Header remediationHeadersCustomName: cs-remediation redisCacheEnabled: false - redisCacheHost: "redis:6379" + redisCacheHost: "redis-primary:6379" + redisCacheReadHosts: + - "redis-replica-1:6379" + - "redis-replica-2:6379" redisCachePassword: password redisCacheDatabase: "5" redisCacheUnreachableBlock: true diff --git a/bouncer.go b/bouncer.go index 39037bd8..961846cc 100644 --- a/bouncer.go +++ b/bouncer.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - htmltemplate "html/template" "io" "log/slog" "net/http" @@ -43,6 +42,7 @@ const ( crowdsecCapiLoginRoute = "v2/watchers/login" crowdsecCapiStreamRoute = "v2/decisions/stream" cacheTimeoutKey = "updated" + appsecAllowAction = "allow" ) // ############################################################## @@ -84,49 +84,67 @@ type Bouncer struct { name string template *template.Template - enabled bool - appsecEnabled bool - appsecScheme string - appsecHost string - appsecPath string - appsecKey string - appsecFailureBlock bool - appsecUnreachableBlock bool - appsecBodyLimit int64 - crowdsecScheme string - crowdsecHost string - crowdsecPath string - crowdsecKey string - crowdsecMode string - crowdsecMachineID string - crowdsecPassword string - crowdsecScenarios []string - updateInterval int64 - updateMaxFailure int64 - defaultDecisionTimeout int64 - remediationStatusCode int - remediationCustomHeader string - forwardedCustomHeader string - crowdsecStreamRoute string - crowdsecHeader string - redisUnreachableBlock bool - banTemplate *htmltemplate.Template - traceCustomHeader string - clientPoolStrategy *ip.PoolStrategy - serverPoolStrategy *ip.PoolStrategy - httpClient *http.Client - httpAppsecClient *http.Client - cacheClient *cache.Client - captchaClient *captcha.Client - log *slog.Logger + enabled bool + appsecEnabled bool + appsecScheme string + appsecHost string + appsecPath string + appsecKey string + appsecFailureBlock bool + appsecUnreachableBlock bool + appsecUnreadableBodyBlock bool + appsecBodyLimit int64 + crowdsecScheme string + crowdsecHost string + crowdsecPath string + crowdsecKey string + crowdsecMode string + crowdsecMachineID string + crowdsecPassword string + crowdsecScenarios []string + updateInterval int64 + updateMaxFailure int64 + defaultDecisionTimeout int64 + remediationStatusCode int + remediationCustomHeader string + forwardedCustomHeader string + crowdsecStreamRoute string + crowdsecHeader string + redisUnreachableBlock bool + banTemplate *template.Template + banTemplateContentType string + traceCustomHeader string + clientPoolStrategy *ip.PoolStrategy + serverPoolStrategy *ip.PoolStrategy + httpClient *http.Client + httpAppsecClient *http.Client + cacheClient *cache.Client + captchaClient *captcha.Client + log *slog.Logger +} + +type AppSecResponse struct { + Action string `json:"action"` + HTTPStatus int `json:"http_status"` + UserBodyContent string `json:"user_body_content,omitempty"` + UserCookies []string `json:"user_cookies,omitempty"` + UserHeaders map[string][]string `json:"user_headers,omitempty"` } // New creates the crowdsec bouncer plugin. // -//nolint:nestif,gocyclo,gocognit +//nolint:nestif,gocyclo,gocognit,funlen,maintidx func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { config.LogLevel = strings.ToUpper(config.LogLevel) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat) + + if config.BanFilePath == "" && config.BanHTMLFilePath != "" { + config.BanFilePath = config.BanHTMLFilePath + } + if config.CaptchaHTMLFilePath != "" { + config.CaptchaFilePath = config.CaptchaHTMLFilePath + } + err := configuration.ValidateParams(config, log) if err != nil { log.Error("New:validateParams " + err.Error()) @@ -184,9 +202,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam } } - var banTemplate *htmltemplate.Template - if config.BanHTMLFilePath != "" { - banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath) + var banTemplate *template.Template + var banTemplateContentType string + if config.BanFilePath != "" { + banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath) } bouncer := &Bouncer{ @@ -194,35 +213,37 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam name: name, template: template.New("CrowdsecBouncer").Delims("[[", "]]"), - enabled: config.Enabled, - crowdsecMode: config.CrowdsecMode, - appsecEnabled: config.CrowdsecAppsecEnabled, - appsecScheme: config.CrowdsecAppsecScheme, - appsecHost: config.CrowdsecAppsecHost, - appsecPath: config.CrowdsecAppsecPath, - appsecKey: config.CrowdsecAppsecKey, - appsecFailureBlock: config.CrowdsecAppsecFailureBlock, - appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, - appsecBodyLimit: config.CrowdsecAppsecBodyLimit, - crowdsecScheme: config.CrowdsecLapiScheme, - crowdsecHost: config.CrowdsecLapiHost, - crowdsecPath: config.CrowdsecLapiPath, - crowdsecKey: config.CrowdsecLapiKey, - crowdsecMachineID: config.CrowdsecCapiMachineID, - crowdsecPassword: config.CrowdsecCapiPassword, - crowdsecScenarios: config.CrowdsecCapiScenarios, - updateInterval: config.UpdateIntervalSeconds, - updateMaxFailure: config.UpdateMaxFailure, - remediationCustomHeader: config.RemediationHeadersCustomName, - forwardedCustomHeader: config.ForwardedHeadersCustomName, - defaultDecisionTimeout: config.DefaultDecisionSeconds, - remediationStatusCode: config.RemediationStatusCode, - redisUnreachableBlock: config.RedisCacheUnreachableBlock, - banTemplate: banTemplate, - traceCustomHeader: config.TraceHeadersCustomName, - crowdsecStreamRoute: crowdsecStreamRoute, - crowdsecHeader: crowdsecHeader, - log: log, + enabled: config.Enabled, + crowdsecMode: config.CrowdsecMode, + appsecEnabled: config.CrowdsecAppsecEnabled, + appsecScheme: config.CrowdsecAppsecScheme, + appsecHost: config.CrowdsecAppsecHost, + appsecPath: config.CrowdsecAppsecPath, + appsecKey: config.CrowdsecAppsecKey, + appsecFailureBlock: config.CrowdsecAppsecFailureBlock, + appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, + appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock, + appsecBodyLimit: config.CrowdsecAppsecBodyLimit, + crowdsecScheme: config.CrowdsecLapiScheme, + crowdsecHost: config.CrowdsecLapiHost, + crowdsecPath: config.CrowdsecLapiPath, + crowdsecKey: config.CrowdsecLapiKey, + crowdsecMachineID: config.CrowdsecCapiMachineID, + crowdsecPassword: config.CrowdsecCapiPassword, + crowdsecScenarios: config.CrowdsecCapiScenarios, + updateInterval: config.UpdateIntervalSeconds, + updateMaxFailure: config.UpdateMaxFailure, + remediationCustomHeader: config.RemediationHeadersCustomName, + forwardedCustomHeader: config.ForwardedHeadersCustomName, + defaultDecisionTimeout: config.DefaultDecisionSeconds, + remediationStatusCode: config.RemediationStatusCode, + redisUnreachableBlock: config.RedisCacheUnreachableBlock, + banTemplate: banTemplate, + banTemplateContentType: banTemplateContentType, + traceCustomHeader: config.TraceHeadersCustomName, + crowdsecStreamRoute: crowdsecStreamRoute, + crowdsecHeader: crowdsecHeader, + log: log, serverPoolStrategy: &ip.PoolStrategy{ Checker: serverChecker, }, @@ -256,6 +277,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam log, config.RedisCacheEnabled, config.RedisCacheHost, + config.RedisCacheReadHosts, config.RedisCachePassword, config.RedisCacheDatabase, ) @@ -276,7 +298,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaSiteKey, config.CaptchaSecretKey, config.RemediationHeadersCustomName, - config.CaptchaHTMLFilePath, + config.CaptchaFilePath, config.CaptchaGracePeriodSeconds, ) if err != nil { @@ -317,7 +339,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam // ServeHTTP principal function of plugin. // -//nolint:nestif,gocyclo +//nolint:nestif func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if !bouncer.enabled { bouncer.next.ServeHTTP(rw, req) @@ -433,14 +455,9 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req if bouncer.remediationCustomHeader != "" { rw.Header().Set(bouncer.remediationCustomHeader, "ban") } - if bouncer.banTemplate == nil { - rw.WriteHeader(bouncer.remediationStatusCode) - return - } - rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.Header().Set("Content-Type", bouncer.banTemplateContentType) rw.WriteHeader(bouncer.remediationStatusCode) - - if req.Method == http.MethodHead { + if bouncer.banTemplate == nil || req.Method == http.MethodHead { return } templateData := map[string]string{ @@ -479,15 +496,49 @@ func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req * func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) { if bouncer.appsecEnabled { - if err := appsecQuery(bouncer, remoteIP, req); err != nil { + decision, err := appsecQuery(bouncer, remoteIP, req) + if err != nil { bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error())) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC) return } + if decision != nil && decision.Action != "" && decision.Action != appsecAllowAction { + bouncer.handleAppsecResponseServeHTTP(rw, req, decision) + return + } } bouncer.next.ServeHTTP(rw, req) } +func (bouncer *Bouncer) handleAppsecResponseServeHTTP(rw http.ResponseWriter, req *http.Request, decision *AppSecResponse) { + atomic.AddInt64(&blockedRequests, 1) + + for name, values := range decision.UserHeaders { + for _, value := range values { + rw.Header().Add(name, value) + } + } + for _, cookie := range decision.UserCookies { + rw.Header().Add("Set-Cookie", cookie) + } + if bouncer.remediationCustomHeader != "" { + rw.Header().Set(bouncer.remediationCustomHeader, decision.Action) + } + + status := decision.HTTPStatus + if status == 0 { + status = bouncer.remediationStatusCode + } + rw.WriteHeader(status) + + if req.Method == http.MethodHead || decision.UserBodyContent == "" { + return + } + if _, err := rw.Write([]byte(decision.UserBodyContent)); err != nil { + bouncer.log.Warn("handleAppsecResponseServeHTTP could not write appsec response: " + err.Error()) + } +} + func handleStreamTicker(bouncer *Bouncer) { if err := handleStreamCache(bouncer); err != nil { bouncer.log.Warn(fmt.Sprintf("handleStreamTicker updateFailure:%d isCrowdsecStreamHealthy:%t %s", updateFailure, isCrowdsecStreamHealthy, err.Error())) @@ -672,6 +723,12 @@ func handleStreamCache(bouncer *Bouncer) error { return nil } +func isReverseProxyError(statusCode int) bool { + return statusCode == http.StatusBadGateway || + statusCode == http.StatusServiceUnavailable || + statusCode == http.StatusGatewayTimeout +} + func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) { var req *http.Request if len(data) > 0 { @@ -683,7 +740,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion) res, err := bouncer.httpClient.Do(req) - if err != nil { + if err != nil || isReverseProxyError(res.StatusCode) { return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err) } defer func() { @@ -711,25 +768,53 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err return body, nil } -func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { +// isBodyUnreadable reports whether the request body cannot be buffered before +// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a +// Content-Length (typically a bidirectional gRPC stream) keeps its body open +// for the whole life of the stream and never reaches EOF, so reading it with +// io.ReadAll would block until the request times out and is wrongly turned into +// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to +// read the body of an HTTP/2+ request that has no Content-Length. +func isBodyUnreadable(httpReq *http.Request) bool { + return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0 +} + +// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body. +func isMethodWithBody(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) (*AppSecResponse, error) { routeURL := url.URL{ Scheme: bouncer.appsecScheme, Host: bouncer.appsecHost, Path: bouncer.appsecPath, } var req *http.Request - if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil { + switch { + case isBodyUnreadable(httpReq): + if bouncer.appsecUnreadableBodyBlock { + // The caller (handleNextServeHTTP) logs this returned error with the IP. + return nil, errors.New("appsecQuery:unreadableBody dropped") + } + req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) + case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil: var bodyBuffer bytes.Buffer limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit) teeReader := io.TeeReader(limitedReader, &bodyBuffer) bodyBytes, err := io.ReadAll(teeReader) if err != nil { - return fmt.Errorf("appsecQuery:GetBody %w", err) + return nil, fmt.Errorf("appsecQuery:GetBody %w", err) } // Conserve body intact after reading it for other middlewares and service httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body)) req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes)) - } else { + default: req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) } @@ -747,12 +832,12 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion) res, err := bouncer.httpAppsecClient.Do(req) - if err != nil { + if err != nil || isReverseProxyError(res.StatusCode) { bouncer.log.Error("appsecQuery:unreachable") if bouncer.appsecUnreachableBlock { - return fmt.Errorf("appsecQuery:unreachable %w", err) + return nil, fmt.Errorf("appsecQuery:unreachable %w", err) } - return nil + return nil, nil } defer func() { if err = res.Body.Close(); err != nil { @@ -762,18 +847,44 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { if res.StatusCode == http.StatusInternalServerError { bouncer.log.Info("appsecQuery:failure") if bouncer.appsecFailureBlock { - return errors.New("appsecQuery statusCode:500") + return nil, errors.New("appsecQuery statusCode:500") } - return nil - } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode) + return nil, nil } + body, err := io.ReadAll(res.Body) if err != nil { - return fmt.Errorf("appsecQuery:readBody %w", err) + return nil, fmt.Errorf("appsecQuery:readBody %w", err) } - return nil + + decision, parseErr := parseAppsecResponse(body) + if parseErr == nil && decision.Action != "" { + return decision, nil + } + + if res.StatusCode == http.StatusOK { + if parseErr != nil && len(bytes.TrimSpace(body)) > 0 { + bouncer.log.Debug("appsecQuery:parseBody " + parseErr.Error()) + } + return nil, nil + } + if parseErr != nil && len(bytes.TrimSpace(body)) > 0 { + bouncer.log.Debug("appsecQuery:parseBody " + parseErr.Error()) + } + return nil, fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode) +} + +func parseAppsecResponse(body []byte) (*AppSecResponse, error) { + body = bytes.TrimSpace(body) + if len(body) == 0 { + return nil, errors.New("empty appsec response body") + } + + var decision AppSecResponse + if err := json.Unmarshal(body, &decision); err != nil { + return nil, err + } + return &decision, nil } func reportMetrics(bouncer *Bouncer) error { diff --git a/bouncer_logging_test.go b/bouncer_logging_test.go index b2834d0e..29dce003 100644 --- a/bouncer_logging_test.go +++ b/bouncer_logging_test.go @@ -32,13 +32,13 @@ func getTestConfig() *configuration.Config { ForwardedHeadersTrustedIPs: []string{"127.0.0.1"}, ForwardedHeadersCustomName: "", RemediationStatusCode: 403, - BanHTMLFilePath: "", + BanFilePath: "", RemediationHeadersCustomName: "", CaptchaProvider: "", CaptchaSiteKey: "", CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1, - CaptchaHTMLFilePath: "", + CaptchaFilePath: "", RedisCacheEnabled: false, RedisCacheHost: "", RedisCachePassword: "", diff --git a/bouncer_test.go b/bouncer_test.go index 3146ed0c..dc42ebb1 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -2,16 +2,20 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck import ( "context" - htmltemplate "html/template" + "io" "net/http" "net/http/httptest" + "net/url" "reflect" + "strings" "testing" "text/template" + "time" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" + logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" ) func TestServeHTTP(t *testing.T) { @@ -190,11 +194,11 @@ func Test_crowdsecQuery(t *testing.T) { func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { html := "You are banned" - banTemplate, _ := htmltemplate.New("html").Parse(html) + banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html) tests := []struct { name string method string - banTemplate *htmltemplate.Template + banTemplate *template.Template expectBodyContent bool }{ { @@ -235,6 +239,7 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { remediationStatusCode: http.StatusForbidden, remediationCustomHeader: "X-Test-Remediation", banTemplate: tt.banTemplate, + banTemplateContentType: "text/html; charset=utf-8", } rw := httptest.NewRecorder() @@ -269,6 +274,50 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { } } +func TestHandleBanServeHTTPContentType(t *testing.T) { + html := "You are banned" + banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html) + tests := []struct { + name string + banTemplate *template.Template + banTemplateContentType string + }{ + { + name: "Default HTML content type", + banTemplate: banTemplate, + banTemplateContentType: "text/html; charset=utf-8", + }, + { + name: "Custom JSON content type", + banTemplate: banTemplate, + banTemplateContentType: "application/json", + }, + { + name: "Content type set even when banTemplate is nil", + banTemplate: nil, + banTemplateContentType: "application/json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bouncer := &Bouncer{ + remediationStatusCode: http.StatusForbidden, + banTemplate: tt.banTemplate, + banTemplateContentType: tt.banTemplateContentType, + } + + rw := httptest.NewRecorder() + req := &http.Request{Method: http.MethodGet} + bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST") + + if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType { + t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got) + } + }) + } +} + func TestCaptchaMethodBasedLogic(t *testing.T) { tests := []struct { name string @@ -332,3 +381,280 @@ func TestCaptchaMethodBasedLogic(t *testing.T) { }) } } + +func TestHandleNextServeHTTPRelaysStructuredAppsecChallenge(t *testing.T) { + appsec := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{ + "action":"challenge", + "http_status":200, + "user_body_content":"challenge", + "user_cookies":["__crowdsec_challenge=value; Path=/; HttpOnly"], + "user_headers":{ + "Content-Type":["text/html"], + "Cache-Control":["no-store"] + } + }`)) + })) + defer appsec.Close() + + appsecURL, err := url.Parse(appsec.URL) + if err != nil { + t.Fatal(err) + } + + nextCalled := false + bouncer := &Bouncer{ + next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true + }), + appsecEnabled: true, + appsecScheme: appsecURL.Scheme, + appsecHost: appsecURL.Host, + appsecPath: "/", + httpAppsecClient: appsec.Client(), + remediationStatusCode: http.StatusForbidden, + remediationCustomHeader: "X-Remediation", + log: logger.New("DEBUG", ""), + } + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) + bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10") + + if nextCalled { + t.Fatal("next handler should not be called for appsec challenge") + } + if recorder.Code != http.StatusOK { + t.Fatalf("expected challenge status 200, got %d", recorder.Code) + } + if got := recorder.Body.String(); got != "challenge" { + t.Fatalf("expected appsec challenge body, got %q", got) + } + if got := recorder.Header().Get("Content-Type"); got != "text/html" { + t.Fatalf("expected Content-Type relayed, got %q", got) + } + if got := recorder.Header().Get("Set-Cookie"); got != "__crowdsec_challenge=value; Path=/; HttpOnly" { + t.Fatalf("expected Set-Cookie relayed, got %q", got) + } + if got := recorder.Header().Get("X-Remediation"); got != "challenge" { + t.Fatalf("expected custom remediation header challenge, got %q", got) + } +} + +func TestHandleNextServeHTTPLegacyAppsecForbiddenFallsBackToBan(t *testing.T) { + appsec := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer appsec.Close() + + appsecURL, err := url.Parse(appsec.URL) + if err != nil { + t.Fatal(err) + } + + nextCalled := false + bouncer := &Bouncer{ + next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true + }), + appsecEnabled: true, + appsecScheme: appsecURL.Scheme, + appsecHost: appsecURL.Host, + appsecPath: "/", + httpAppsecClient: appsec.Client(), + remediationStatusCode: http.StatusForbidden, + remediationCustomHeader: "X-Remediation", + log: logger.New("DEBUG", ""), + } + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) + bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10") + + if nextCalled { + t.Fatal("next handler should not be called for appsec forbidden") + } + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected fallback ban status 403, got %d", recorder.Code) + } + if got := recorder.Header().Get("X-Remediation"); got != "ban" { + t.Fatalf("expected fallback remediation header ban, got %q", got) + } +} + +// blockingBody simulates a request body that never reaches EOF, like a +// bidirectional gRPC stream that keeps its body open for the whole life of +// the connection. Reading from it blocks until the test is done. +type blockingBody struct { + done <-chan struct{} +} + +func (b blockingBody) Read(_ []byte) (int, error) { + <-b.done + return 0, io.EOF +} + +func (blockingBody) Close() error { return nil } + +func Test_isBodyUnreadable(t *testing.T) { + realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) } + tests := []struct { + name string + protoMajor int + contentLength int64 + body io.ReadCloser + want bool + }{ + {name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true}, + {name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true}, + {name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false}, + {name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false}, + {name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false}, + {name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil) + req.ProtoMajor = tt.protoMajor + req.ContentLength = tt.contentLength + req.Body = tt.body + if got := isBodyUnreadable(req); got != tt.want { + t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want) + } + }) + } +} + +// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF, +// like a bidirectional gRPC stream (issue #323). +func newStreamingRequest(done <-chan struct{}) *http.Request { + req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done}) + req.Header.Set("Content-Type", "application/grpc") + req.ProtoMajor = 2 + req.ContentLength = -1 + return req +} + +// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323: +// a gRPC streaming request whose body never reaches EOF must not be buffered +// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec +// query must complete promptly, inspecting headers only. +func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) { + appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer appsecServer.Close() + + appsecURL, _ := url.Parse(appsecServer.URL) + bouncer := &Bouncer{ + appsecScheme: appsecURL.Scheme, + appsecHost: appsecURL.Host, + appsecPath: "/", + appsecBodyLimit: 10485760, + appsecUnreachableBlock: true, + appsecFailureBlock: true, + httpAppsecClient: appsecServer.Client(), + log: logger.New("INFO", ""), + } + + done := make(chan struct{}) + defer close(done) + + finished := make(chan error, 1) + go func() { + _, err := appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done)) + finished <- err + }() + + select { + case err := <-finished: + if err != nil { + t.Errorf("appsecQuery() on streaming request returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)") + } +} + +// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so, +// a request with an unreadable body is dropped (blocked) instead of forwarded +// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option. +func Test_appsecQuery_dropUnreadableBody(t *testing.T) { + appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer appsecServer.Close() + + appsecURL, _ := url.Parse(appsecServer.URL) + bouncer := &Bouncer{ + appsecScheme: appsecURL.Scheme, + appsecHost: appsecURL.Host, + appsecPath: "/", + appsecBodyLimit: 10485760, + appsecUnreadableBodyBlock: true, + httpAppsecClient: appsecServer.Client(), + log: logger.New("INFO", ""), + } + + done := make(chan struct{}) + defer close(done) + + finished := make(chan error, 1) + go func() { + _, err := appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done)) + finished <- err + }() + + select { + case err := <-finished: + if err == nil { + t.Error("appsecQuery() expected an error to block the request, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)") + } +} + +func newUnreadableGetRequest(done <-chan struct{}) *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://localhost/", blockingBody{done: done}) + req.ProtoMajor = 3 + req.ContentLength = -1 + return req +} + +// Test_appsecQuery_unreadableBodyGetNotDropped is a regression test for issue #351 +func Test_appsecQuery_unreadableBodyGetNotDropped(t *testing.T) { + appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer appsecServer.Close() + + appsecURL, _ := url.Parse(appsecServer.URL) + bouncer := &Bouncer{ + appsecScheme: appsecURL.Scheme, + appsecHost: appsecURL.Host, + appsecPath: "/", + appsecBodyLimit: 10485760, + appsecUnreadableBodyBlock: true, + httpAppsecClient: appsecServer.Client(), + log: logger.New("INFO", ""), + } + + done := make(chan struct{}) + defer close(done) + + finished := make(chan error, 1) + go func() { + finished <- appsecQuery(bouncer, "1.2.3.4", newUnreadableGetRequest(done)) + }() + + select { + case err := <-finished: + if err != nil { + t.Errorf("appsecQuery() on an HTTP/3 GET without content-length returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("appsecQuery() blocked on an HTTP/3 GET request body (issue #351 regression)") + } +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a746d4e0..7bce7558 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.5.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -80,7 +80,7 @@ services: - "traefik.http.routers.router-bar3.entrypoints=web" - "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker" crowdsec: - image: crowdsecurity/crowdsec:v1.6.8 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/docker-compose.yml b/docker-compose.yml index 8db2de86..225ce810 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -12,7 +12,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" # - './ban.html:/ban.html:ro' @@ -59,7 +59,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5" crowdsec: - image: crowdsecurity/crowdsec:v1.6.8 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/appsec-enabled/docker-compose.yml b/examples/appsec-enabled/docker-compose.yml index 5834f2b8..79fae60e 100644 --- a/examples/appsec-enabled/docker-compose.yml +++ b/examples/appsec-enabled/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.5.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.5.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -47,7 +47,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/behind-proxy/docker-compose.yml b/examples/behind-proxy/docker-compose.yml index 36faf4c8..f7c138d5 100644 --- a/examples/behind-proxy/docker-compose.yml +++ b/examples/behind-proxy/docker-compose.yml @@ -1,6 +1,6 @@ services: cloudflare: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "cloudflare" restart: unless-stopped command: @@ -19,7 +19,7 @@ services: - 8080:8080 traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -33,7 +33,7 @@ services: - "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - logs-traefik:/var/log/traefik @@ -79,7 +79,7 @@ services: crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/captcha/README.md b/examples/captcha/README.md index 95c825b8..59788db3 100644 --- a/examples/captcha/README.md +++ b/examples/captcha/README.md @@ -52,7 +52,7 @@ More information is available on configuring Crowdsec in the [official documenta ```yaml ... crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.6.8 volumes: # For captcha and ban mixed decision - './profiles.yaml:/etc/crowdsec/profiles.yaml:ro' diff --git a/examples/captcha/docker-compose.yml b/examples/captcha/docker-compose.yml index d3387e30..a17d3466 100644 --- a/examples/captcha/docker-compose.yml +++ b/examples/captcha/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -55,7 +55,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/custom-ban-page/README.md b/examples/custom-ban-page/README.md index 21c159da..40746420 100644 --- a/examples/custom-ban-page/README.md +++ b/examples/custom-ban-page/README.md @@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we ```yaml labels: - # Define ban HTML file path - - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html" + # Define ban file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" ``` -The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build). +The ban file must be present in the Traefik container (bind mounted or added during a custom build). It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik. ```yaml diff --git a/examples/custom-ban-page/docker-compose.yml b/examples/custom-ban-page/docker-compose.yml index 74c61e53..6dca9aa9 100644 --- a/examples/custom-ban-page/docker-compose.yml +++ b/examples/custom-ban-page/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -42,11 +42,11 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - # Define ban HTML file path - - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html" + # Define ban file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/custom-captcha/docker-compose.yml b/examples/custom-captcha/docker-compose.yml index be3a1256..d1c035e4 100644 --- a/examples/custom-captcha/docker-compose.yml +++ b/examples/custom-captcha/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.5.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -14,7 +14,7 @@ services: - "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.4.5" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -59,7 +59,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/kubernetes/crowdsec/values.yml b/examples/kubernetes/crowdsec/values.yml index 0204d183..6dc68f8c 100644 --- a/examples/kubernetes/crowdsec/values.yml +++ b/examples/kubernetes/crowdsec/values.yml @@ -1,5 +1,5 @@ image: - tag: v1.6.1-2 + tag: v1.7.8-2 agent: acquisition: diff --git a/examples/kubernetes/traefik/values.yml b/examples/kubernetes/traefik/values.yml index 72ed5e93..a56bee3c 100644 --- a/examples/kubernetes/traefik/values.yml +++ b/examples/kubernetes/traefik/values.yml @@ -1,5 +1,5 @@ image: - tag: v3.0.0 + tag: v3.7.9 logs: general: @@ -15,4 +15,4 @@ experimental: plugins: bouncer: moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - version: "v1.3.0" + version: "v1.7.0" diff --git a/examples/redis-cache/docker-compose.yml b/examples/redis-cache/docker-compose.yml index 1e3a52cb..a0b660cd 100644 --- a/examples/redis-cache/docker-compose.yml +++ b/examples/redis-cache/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -71,7 +71,7 @@ services: crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: @@ -87,7 +87,7 @@ services: - "traefik.enable=false" redis-secure: - image: "redis:7.0.12-alpine" + image: "redis:8.8.1-alpine" container_name: "redis-secure" hostname: redis-secure restart: unless-stopped diff --git a/examples/standalone-mode/docker-compose.yml b/examples/standalone-mode/docker-compose.yml index 9a48d1f3..bc1c7ad3 100644 --- a/examples/standalone-mode/docker-compose.yml +++ b/examples/standalone-mode/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/examples/tls-auth/Dockerfile b/examples/tls-auth/Dockerfile index b936de96..a725e25f 100644 --- a/examples/tls-auth/Dockerfile +++ b/examples/tls-auth/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:24.04 +FROM ubuntu:26.04 RUN apt-get update && apt-get install -y curl wget RUN VERSION=$(curl --silent "https://api.github.com/repos/cloudflare/cfssl/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') && VNUMBER=${VERSION#"v"} && wget https://github.com/cloudflare/cfssl/releases/download/${VERSION}/cfssl_${VNUMBER}_linux_amd64 -O cfssl && chmod +x cfssl && mv cfssl /usr/local/bin diff --git a/examples/tls-auth/docker-compose.yml b/examples/tls-auth/docker-compose.yml index d1d6c487..c1dd4271 100644 --- a/examples/tls-auth/docker-compose.yml +++ b/examples/tls-auth/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.5.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.5.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -71,7 +71,7 @@ services: # Define AppSec host and port informations - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422" crowdsec: - image: crowdsecurity/crowdsec:latest + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/examples/trusted-ips/docker-compose.yml b/examples/trusted-ips/docker-compose.yml index 987bc538..f6b598d4 100644 --- a/examples/trusted-ips/docker-compose.yml +++ b/examples/trusted-ips/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.7.9" container_name: "traefik" restart: unless-stopped command: @@ -13,7 +13,7 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.3.0" + - "--experimental.plugins.bouncer.version=v1.7.0" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -65,7 +65,7 @@ services: crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.7.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/go.mod b/go.mod index 79067e41..c0bed0f2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin -go 1.22 +go 1.22.12 require ( github.com/leprosus/golang-ttl-map v1.1.7 diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index e9666387..3059f01f 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" ttl_map "github.com/leprosus/golang-ttl-map" simpleredis "github.com/maxlerebourg/simpleredis" @@ -27,10 +28,7 @@ const ( ) //nolint:gochecknoglobals -var ( - redis simpleredis.SimpleRedis - cache = ttl_map.New() -) +var cache = ttl_map.New() type localCache struct{} @@ -52,33 +50,48 @@ func (localCache) delete(key string) { } type redisCache struct { - log *slog.Logger + log *slog.Logger + writer simpleredis.SimpleRedis + readers []simpleredis.SimpleRedis + counter atomic.Uint64 } -func (redisCache) get(key string) (string, error) { - value, err := redis.Get(key) - valueString := string(value) - if err == nil && len(valueString) > 0 { - return valueString, nil +func (rc *redisCache) nextReader() *simpleredis.SimpleRedis { + n := len(rc.readers) + if n == 0 { + return &rc.writer } - errRedisMessage := err.Error() - if errRedisMessage == simpleredis.RedisMiss { - return "", errors.New(CacheMiss) + idx := rc.counter.Add(1) % uint64(n) + return &rc.readers[idx] +} + +func (rc *redisCache) get(key string) (string, error) { + value, err := rc.nextReader().Get(key) + if err != nil { + switch err.Error() { + case simpleredis.RedisMiss: + return "", errors.New(CacheMiss) + case simpleredis.RedisUnreachable: + return "", errors.New(CacheUnreachable) + default: + return "", err + } } - if errRedisMessage == simpleredis.RedisUnreachable { - return "", errors.New(CacheUnreachable) + valueString := string(value) + if len(valueString) > 0 { + return valueString, nil } - return "", err + return "", errors.New(CacheMiss) } -func (rc redisCache) set(key, value string, duration int64) { - if err := redis.Set(key, []byte(value), duration); err != nil { +func (rc *redisCache) set(key, value string, duration int64) { + if err := rc.writer.Set(key, []byte(value), duration); err != nil { rc.log.Error("cache:setDecisionRedisCache" + err.Error()) } } -func (rc redisCache) delete(key string) { - if err := redis.Del(key); err != nil { +func (rc *redisCache) delete(key string) { + if err := rc.writer.Del(key); err != nil { rc.log.Error("cache:deleteDecisionRedisCache " + err.Error()) } } @@ -96,15 +109,21 @@ type Client struct { } // New Initialize cache client. -func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) { +func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) { c.log = log if isRedis { - redis.Init(host, pass, database) - c.cache = &redisCache{log: log} + rc := &redisCache{log: log} + rc.writer.Init(writeHost, pass, database) + for _, h := range readHosts { + var r simpleredis.SimpleRedis + r.Init(h, pass, database) + rc.readers = append(rc.readers, r) + } + c.cache = rc } else { c.cache = &localCache{} } - c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis)) + c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts)) } // Delete delete decision in cache. diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index ce62d6e9..3aa3aea0 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -6,6 +6,7 @@ import ( "testing" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" + simpleredis "github.com/maxlerebourg/simpleredis" ) func Test_Get(t *testing.T) { @@ -122,3 +123,40 @@ func Test_Delete(t *testing.T) { }) } } + +// indexOfReader returns the position of r inside rc.readers, or -1 when r is the writer (the no-readers fallback). +func indexOfReader(rc *redisCache, r *simpleredis.SimpleRedis) int { + if r == &rc.writer { + return -1 + } + for i := range rc.readers { + if r == &rc.readers[i] { + return i + } + } + return -2 +} + +func Test_nextReader(t *testing.T) { + // The counter starts at 0, so the first Add(1) yields index 1, then 2, 0, 1, ... over n readers. + tests := []struct { + name string + readers int + want []int + }{ + {name: "round-robin over three readers", readers: 3, want: []int{1, 2, 0, 1, 2, 0, 1}}, + {name: "single reader always selected", readers: 1, want: []int{0, 0, 0, 0, 0}}, + {name: "no readers fall back to writer", readers: 0, want: []int{-1, -1, -1}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc := &redisCache{log: logger.New("INFO", "")} + rc.readers = make([]simpleredis.SimpleRedis, tt.readers) + for call, want := range tt.want { + if got := indexOfReader(rc, rc.nextReader()); got != want { + t.Errorf("call %d: nextReader() -> reader[%d], want reader[%d]", call, got, want) + } + } + }) + } +} diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index c356779d..ce205066 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -4,11 +4,11 @@ package captcha import ( "encoding/json" "fmt" - "html/template" "log/slog" "net/http" "net/url" "strings" + "text/template" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" @@ -21,7 +21,8 @@ type Client struct { secretKey string remediationCustomHeader string gracePeriodSeconds int64 - captchaTemplate *template.Template + templateContentType string + template *template.Template cacheClient *cache.Client httpClient *http.Client log *slog.Logger @@ -74,8 +75,9 @@ func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *ht c.siteKey = siteKey c.secretKey = secretKey c.remediationCustomHeader = remediationCustomHeader - html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) - c.captchaTemplate = html + template, contentType, _ := configuration.GetTemplate(captchaTemplatePath) + c.template = template + c.templateContentType = contentType c.gracePeriodSeconds = gracePeriodSeconds c.log = log c.httpClient = httpClient @@ -100,12 +102,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str http.Redirect(rw, r, r.URL.String(), http.StatusFound) return } - rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.Header().Set("Content-Type", c.templateContentType) if c.remediationCustomHeader != "" { rw.Header().Set(c.remediationCustomHeader, "captcha") } rw.WriteHeader(http.StatusOK) - err = c.captchaTemplate.Execute(rw, map[string]string{ + err = c.template.Execute(rw, map[string]string{ "SiteKey": c.siteKey, "FrontendJS": c.infoProvider.js, "FrontendKey": c.infoProvider.key, diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index b8411e5a..6a7a2e53 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -6,7 +6,6 @@ import ( "crypto/x509" "errors" "fmt" - "html/template" "log/slog" "net/http" "net/url" @@ -15,6 +14,7 @@ import ( "reflect" "regexp" "strings" + "text/template" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ) @@ -63,6 +63,7 @@ type Config struct { CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` + CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` @@ -95,12 +96,15 @@ type Config struct { ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"` RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"` RedisCacheHost string `json:"redisCacheHost,omitempty"` + RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"` - BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` - CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` + BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility + BanFilePath string `json:"banFilePath,omitempty"` + CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility + CaptchaFilePath string `json:"captchaFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` @@ -125,52 +129,54 @@ func contains(source []string, target string) bool { // New creates the default plugin configuration. func New() *Config { return &Config{ - Enabled: false, - LogLevel: LogINFO, - LogFormat: "common", - LogFilePath: "", - CrowdsecMode: LiveMode, - CrowdsecAppsecEnabled: false, - CrowdsecAppsecFailureBlock: true, - CrowdsecAppsecUnreachableBlock: true, - CrowdsecAppsecBodyLimit: 10485760, - CrowdsecAppsecScheme: "", - CrowdsecAppsecHost: "crowdsec:7422", - CrowdsecAppsecPath: "/", - CrowdsecAppsecKey: "", - CrowdsecAppsecTLSInsecureVerify: false, - CrowdsecLapiScheme: HTTP, - CrowdsecLapiHost: "crowdsec:8080", - CrowdsecLapiPath: "/", - CrowdsecLapiKey: "", - CrowdsecLapiTLSInsecureVerify: false, - UpdateIntervalSeconds: 60, - MetricsUpdateIntervalSeconds: 600, - UpdateMaxFailure: 0, - StreamStartupBlock: true, - DefaultDecisionSeconds: 60, - RemediationStatusCode: http.StatusForbidden, - HTTPTimeoutSeconds: 10, - CaptchaProvider: "", - CaptchaCustomJsURL: "", - CaptchaCustomValidateURL: "", - CaptchaCustomKey: "", - CaptchaCustomResponse: "", - CaptchaSiteKey: "", - CaptchaSecretKey: "", - CaptchaGracePeriodSeconds: 1800, - CaptchaHTMLFilePath: "/captcha.html", - BanHTMLFilePath: "", - TraceHeadersCustomName: "", - RemediationHeadersCustomName: "", - ForwardedHeadersCustomName: "X-Forwarded-For", - ForwardedHeadersTrustedIPs: []string{}, - ClientTrustedIPs: []string{}, - RedisCacheEnabled: false, - RedisCacheHost: "redis:6379", - RedisCachePassword: "", - RedisCacheDatabase: "", - RedisCacheUnreachableBlock: true, + Enabled: false, + LogLevel: LogINFO, + LogFormat: "common", + LogFilePath: "", + CrowdsecMode: LiveMode, + CrowdsecAppsecEnabled: false, + CrowdsecAppsecFailureBlock: true, + CrowdsecAppsecUnreachableBlock: true, + CrowdsecAppsecUnreadableBodyBlock: true, + CrowdsecAppsecBodyLimit: 10485760, + CrowdsecAppsecScheme: "", + CrowdsecAppsecHost: "crowdsec:7422", + CrowdsecAppsecPath: "/", + CrowdsecAppsecKey: "", + CrowdsecAppsecTLSInsecureVerify: false, + CrowdsecLapiScheme: HTTP, + CrowdsecLapiHost: "crowdsec:8080", + CrowdsecLapiPath: "/", + CrowdsecLapiKey: "", + CrowdsecLapiTLSInsecureVerify: false, + UpdateIntervalSeconds: 60, + MetricsUpdateIntervalSeconds: 600, + UpdateMaxFailure: 0, + StreamStartupBlock: true, + DefaultDecisionSeconds: 60, + RemediationStatusCode: http.StatusForbidden, + HTTPTimeoutSeconds: 10, + CaptchaProvider: "", + CaptchaCustomJsURL: "", + CaptchaCustomValidateURL: "", + CaptchaCustomKey: "", + CaptchaCustomResponse: "", + CaptchaSiteKey: "", + CaptchaSecretKey: "", + CaptchaGracePeriodSeconds: 1800, + CaptchaFilePath: "/captcha.html", + BanFilePath: "", + TraceHeadersCustomName: "", + RemediationHeadersCustomName: "", + ForwardedHeadersCustomName: "X-Forwarded-For", + ForwardedHeadersTrustedIPs: []string{}, + ClientTrustedIPs: []string{}, + RedisCacheEnabled: false, + RedisCacheHost: "redis:6379", + RedisCacheReadHosts: []string{}, + RedisCachePassword: "", + RedisCacheDatabase: "", + RedisCacheUnreachableBlock: true, } } @@ -201,28 +207,50 @@ func GetVariable(config *Config, key string) (string, error) { return strings.TrimSpace(value), nil } -// GetHTMLTemplate get compiled HTML template. -func GetHTMLTemplate(path string) (*template.Template, error) { - var err error +func getContentTypeFromPath(path string) string { if path == "" { - return nil, errors.New("no html template provided") + return "" + } + ext := strings.ToLower(filepath.Ext(path)) + contentTypeMap := map[string]string{ + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".json": "application/json", + ".txt": "text/plain", + ".xml": "application/xml", + ".js": "application/javascript", + ".css": "text/css", + } + if contentType, ok := contentTypeMap[ext]; ok { + return contentType + } + // Default to HTML for backward compatibility + return "text/html; charset=utf-8" +} + +// GetTemplate get compiled template with {{ and }} delimiters. +// Uses text/template for all file types to avoid HTML escaping issues. +func GetTemplate(path string) (*template.Template, string, error) { + if path == "" { + return nil, "", errors.New("no template file provided") } + contentType := getContentTypeFromPath(path) //nolint:gosec b, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, "", err } - html := string(b) - compiledTemplate, err := template.New("html").Parse(html) + content := string(b) + compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content) if err != nil { - return nil, fmt.Errorf("impossible to compile html template: %w", err) + return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err) } - return compiledTemplate, nil + return compiledTemplate, contentType, nil } // ValidateParams validate all the param gave by user. // -//nolint:gocyclo,gocognit +//nolint:gocyclo,gocognit,nestif func ValidateParams(config *Config, log *slog.Logger) error { if err := validateParamsRequired(config); err != nil { return err @@ -260,12 +288,14 @@ func ValidateParams(config *Config, log *slog.Logger) error { if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil { return err } - if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil { - return err + if config.CaptchaFilePath != "" { + if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil { + return err + } } } - if config.BanHTMLFilePath != "" { - if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil { + if config.BanFilePath != "" { + if _, _, err := GetTemplate(config.BanFilePath); err != nil { return err } } diff --git a/pkg/configuration/configuration_test.go b/pkg/configuration/configuration_test.go index e1c4e618..c6a92406 100644 --- a/pkg/configuration/configuration_test.go +++ b/pkg/configuration/configuration_test.go @@ -301,3 +301,28 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) { }) } } + +func Test_getContentTypeFromPath(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + {name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"}, + {name: "JSON file", path: "/ban.json", expected: "application/json"}, + {name: "Text file", path: "/ban.txt", expected: "text/plain"}, + {name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"}, + {name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"}, + {name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"}, + {name: "Empty path", path: "", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getContentTypeFromPath(tt.path) + if got != tt.expected { + t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected) + } + }) + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..51ef9cac --- /dev/null +++ b/renovate.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "gitAuthor": "Renovate Bot <22881669+maxlerebourg@users.noreply.github.com>", + "fetchChangeLogs": "off", + "labels": ["dependencies"], + "ignorePaths": ["**/vendor/**", "**/node_modules/**"], + "rangeStrategy": "bump", + "prConcurrentLimit": 1, + "branchPrefix": "renovate/", + "commitMessagePrefix": "⬆️ renovate: ", + "groupName": "all", + "dependencyDashboard": false, + "packageRules": [ + { + "description": "Cap the Go version at what yaegi supports. The plugin is interpreted by yaegi (bundled in Traefik), and even Traefik v3.7.1 ships yaegi v0.16.1 = Go 1.22. A newer Go would break the plugin on every current Traefik. Raise this only once Traefik ships a yaegi supporting a newer Go.", + "matchManagers": ["gomod"], + "matchDepNames": ["go", "toolchain"], + "allowedVersions": "<1.23" + }, + { + "description": "whoami is a throwaway demo backend; leave it on latest", + "matchPackageNames": ["traefik/whoami"], + "enabled": false + } + ], + "customManagers": [ + { + "description": "Plugin self-pin in docker-compose CLI args (--experimental.plugins.bouncer.version=vX)", + "customType": "regex", + "managerFilePatterns": ["/(^|/)docker-compose[^/]*\\.ya?ml$/"], + "matchStrings": [ + "experimental\\.plugins\\.bouncer\\.version=(?v[0-9]+\\.[0-9]+\\.[0-9]+)" + ], + "depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin", + "datasourceTemplate": "github-tags" + }, + { + "description": "Plugin self-pin in the Traefik Helm values (version: \"vX\")", + "customType": "regex", + "managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"], + "matchStrings": [ + "version:\\s*\"(?v[0-9]+\\.[0-9]+\\.[0-9]+)\"" + ], + "depNameTemplate": "maxlerebourg/crowdsec-bouncer-traefik-plugin", + "datasourceTemplate": "github-tags" + }, + { + "description": "Traefik image tag in the Traefik Helm values (no repository key, so match by file)", + "customType": "regex", + "managerFilePatterns": ["/^examples/kubernetes/traefik/values\\.ya?ml$/"], + "matchStrings": ["tag:\\s*(?v[0-9]+\\.[0-9]+\\.[0-9]+)"], + "depNameTemplate": "traefik", + "datasourceTemplate": "docker" + }, + { + "description": "Crowdsec image tag in the Crowdsec Helm values (no repository key, so match by file)", + "customType": "regex", + "managerFilePatterns": [ + "/^examples/kubernetes/crowdsec/values\\.ya?ml$/" + ], + "matchStrings": ["tag:\\s*(?v[0-9]+\\.[0-9]+\\.[0-9]+)"], + "depNameTemplate": "crowdsecurity/crowdsec", + "datasourceTemplate": "docker" + }, + { + "description": "Pinned Traefik binary in the e2e mock suite (TRAEFIK_VERSION:-vX in common.sh)", + "customType": "regex", + "managerFilePatterns": ["/^tests/e2e/mock/lib/common\\.sh$/"], + "matchStrings": [ + "TRAEFIK_VERSION:-(?v[0-9]+\\.[0-9]+\\.[0-9]+)" + ], + "depNameTemplate": "traefik/traefik", + "datasourceTemplate": "github-releases" + } + ] +} diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh index d32e85dd..3dc5f1dd 100644 --- a/tests/e2e/mock/lib/common.sh +++ b/tests/e2e/mock/lib/common.sh @@ -14,12 +14,14 @@ set -euo pipefail # Pinned to match the Docker suite (tests/e2e/scenarios/*/docker-compose.yml). -TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.1}" +TRAEFIK_VERSION="${TRAEFIK_VERSION:-v3.7.9}" WEB_PORT="${WEB_PORT:-8000}" LAPI_PORT="${LAPI_PORT:-8090}" BACKEND_PORT="${BACKEND_PORT:-8091}" APPSEC_PORT="${APPSEC_PORT:-8092}" +REDIS_PORT="${REDIS_PORT:-8093}" +REDIS_READ_PORT="${REDIS_READ_PORT:-8094}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -187,6 +189,8 @@ start_stack() { -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|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \ + -e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \ -e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \ "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" @@ -203,6 +207,8 @@ start_stack() { --lapi-addr "127.0.0.1:${LAPI_PORT}" \ --backend-addr "127.0.0.1:${BACKEND_PORT}" \ --appsec-addr "127.0.0.1:${APPSEC_PORT}" \ + --redis-addr "127.0.0.1:${REDIS_PORT}" \ + --redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \ "${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 & MOCK_PID=$! diff --git a/tests/e2e/mock/mocklapi/go.mod b/tests/e2e/mock/mocklapi/go.mod index bd05338e..fdc497c6 100644 --- a/tests/e2e/mock/mocklapi/go.mod +++ b/tests/e2e/mock/mocklapi/go.mod @@ -3,4 +3,4 @@ // golangci-lint and `go mod vendor`. Stdlib only — no dependencies. module mocklapi -go 1.22 +go 1.22.12 diff --git a/tests/e2e/mock/mocklapi/main.go b/tests/e2e/mock/mocklapi/main.go index a2804bdd..65f5ad67 100644 --- a/tests/e2e/mock/mocklapi/main.go +++ b/tests/e2e/mock/mocklapi/main.go @@ -2,7 +2,8 @@ // suite. It answers only the few LAPI routes the plugin calls — live/none // decision lookups, the stream poll and the usage-metrics push — and lets 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, and a hardcoded Redis +// stand-in for exercising the redis cache path. // // 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 @@ -11,9 +12,12 @@ package main import ( + "bufio" "encoding/json" "flag" + "io" "log" + "net" "net/http" "strings" "sync" @@ -45,6 +49,50 @@ func list(m map[string]Decision) []Decision { return out } +// --- Redis mock (inline-command wire format, as spoken by simpleredis) --- + +// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica +// that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f" +// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts +// is false it plays the primary and answers every GET with a miss, so a +// scenario can prove reads are served from the replica and not the primary. +// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway). +func serveRedis(addr string, verdicts bool) { + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + + for { + conn, err := ln.Accept() + if err != nil { + continue + } + go func(conn net.Conn) { + defer conn.Close() + rd := bufio.NewReader(conn) + for { + line, _, err := rd.ReadLine() + if err != nil { + return + } + s := string(line) + switch { + case verdicts && strings.Contains(s, "1.2.3.4"): + conn.Write([]byte("$1\r\nf\r\n")) + case verdicts && strings.Contains(s, "1.2.3.5"): + conn.Write([]byte("$1\r\nt\r\n")) + case strings.HasPrefix(strings.ToUpper(s), "GET "): + conn.Write([]byte("$-1\r\n")) + default: + conn.Write([]byte("+OK\r\n")) + } + } + }(conn) + } +} + func main() { lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock") // The stub upstream Traefik proxies allowed requests to — the binary-suite @@ -52,6 +100,12 @@ func main() { 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") + // Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis + // cache path. The primary answers every GET with a miss; the replica serves + // the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the + // replica proves reads are offloaded to replicas. + redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)") + redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)") // Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS // (cert signed by the scenario's throwaway CA) so the suite can exercise the // bouncer's system-trust-store path. Backend and AppSec stay plaintext. @@ -72,12 +126,32 @@ func main() { // 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") { + if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "403") { w.WriteHeader(http.StatusForbidden) } + if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "500") { + w.WriteHeader(http.StatusInternalServerError) + } + if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "502") { + w.WriteHeader(http.StatusBadGateway) + } + // Read body + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer r.Body.Close() + if strings.Contains(string(body), "a=0") { + w.WriteHeader(http.StatusForbidden) + return + } }))) }() + go serveRedis(*redisAddr, false) + go serveRedis(*redisReadAddr, true) + mux := http.NewServeMux() // Readiness probe for the test harness (empty body, 200). @@ -136,9 +210,9 @@ func main() { }) if *lapiTLSCert != "" && *lapiTLSKey != "" { - log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr) + log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr) log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux)) } - log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr) + log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr) log.Fatal(http.ListenAndServe(*lapiAddr, mux)) } diff --git a/tests/e2e/mock/scenarios/appsec/dynamic.yml b/tests/e2e/mock/scenarios/appsec/dynamic.yml index 05fa8d91..f02b2866 100644 --- a/tests/e2e/mock/scenarios/appsec/dynamic.yml +++ b/tests/e2e/mock/scenarios/appsec/dynamic.yml @@ -18,11 +18,14 @@ http: bouncer: enabled: "true" # IP bouncing disabled — this scenario exercises AppSec only. - crowdsecMode: none + crowdsecMode: appsec crowdsecLapiScheme: http crowdsecLapiHost: "@@LAPI_HOST@@" crowdsecLapiKey: "@@APIKEY@@" crowdsecAppsecEnabled: "true" + crowdsecAppsecFailureBlock: "true" + crowdsecAppsecBodyLimit: 4 + crowdsecAppsecUnreachableBlock: "false" crowdsecAppsecScheme: http crowdsecAppsecHost: "@@APPSEC_HOST@@" forwardedHeadersTrustedIps: diff --git a/tests/e2e/mock/scenarios/appsec/run.sh b/tests/e2e/mock/scenarios/appsec/run.sh index 1e1d2bdf..5427e5c1 100755 --- a/tests/e2e/mock/scenarios/appsec/run.sh +++ b/tests/e2e/mock/scenarios/appsec/run.sh @@ -13,11 +13,32 @@ SCENARIO=appsec # 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)" + echo "[$SCENARIO] benign request must pass (AppSec 200)" 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" + echo "[$SCENARIO] request that return 403 must be blocked (AppSec 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo/403" 403 -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] request that return 500 must be blocked (because CrowdsecAppsecFailureBlock = true) (AppSec 500)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo/500" 403 -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] request that return 502 must pass (because CrowdsecAppsecUnreachableBlock = false) (Proxy error 502)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo/502" 200 -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] request that send bad body after crowdsecAppsecBodyLimit must pass (AppSec 200)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "______&a=0" + + echo "[$SCENARIO] request that send bad body before crowdsecAppsecBodyLimit must pass (AppSec 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "a=0&______" + + echo "[$SCENARIO] request http2 that send no body GET (AppSec 200)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" + + echo "[$SCENARIO] request http2 that send unreadable body GET (AppSec 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -d "test" + + echo "[$SCENARIO] request http2 that send unreadable body POST (AppSec 403)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -X POST -d "test" } run_scenario "$SCENARIO" "$HERE" body diff --git a/tests/e2e/mock/scenarios/captcha/run.sh b/tests/e2e/mock/scenarios/captcha/run.sh index 86ed301a..2dd53ad4 100755 --- a/tests/e2e/mock/scenarios/captcha/run.sh +++ b/tests/e2e/mock/scenarios/captcha/run.sh @@ -16,6 +16,9 @@ body() { echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)" wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4" + echo "[$SCENARIO] captcha response Content-Type is HTML" + assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4" + echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)" assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" diff --git a/tests/e2e/mock/scenarios/custom-ban-page/ban.html b/tests/e2e/mock/scenarios/custom-ban-page/ban.html deleted file mode 100644 index 62c0fe39..00000000 --- a/tests/e2e/mock/scenarios/custom-ban-page/ban.html +++ /dev/null @@ -1,8 +0,0 @@ - - -E2E ban marker - -

E2E_CUSTOM_BAN_PAGE_MARKER

-

IP: {{ .ClientIP }} reason: {{ .RemediationReason }}

- - diff --git a/tests/e2e/mock/scenarios/custom-ban-page/ban.json b/tests/e2e/mock/scenarios/custom-ban-page/ban.json new file mode 100644 index 00000000..b4e45ef8 --- /dev/null +++ b/tests/e2e/mock/scenarios/custom-ban-page/ban.json @@ -0,0 +1,4 @@ +{ + "marker": "E2E_CUSTOM_BAN_PAGE_MARKER", + "body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}" +} diff --git a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml index 2bfbdf60..573ad079 100644 --- a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml +++ b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml @@ -24,5 +24,6 @@ http: crowdsecLapiKey: "@@APIKEY@@" forwardedHeadersTrustedIps: - "127.0.0.1/32" - banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html" + banFilePath: "@@SCENARIO_DIR@@/ban.json" remediationHeadersCustomName: "X-E2E-Remediation" + traceHeadersCustomName: x-trace diff --git a/tests/e2e/mock/scenarios/custom-ban-page/run.sh b/tests/e2e/mock/scenarios/custom-ban-page/run.sh index c12b2177..e040f374 100755 --- a/tests/e2e/mock/scenarios/custom-ban-page/run.sh +++ b/tests/e2e/mock/scenarios/custom-ban-page/run.sh @@ -15,11 +15,14 @@ body() { wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4" echo "[$SCENARIO] banned response Content-Type is HTML" - assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4" + assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4" 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 body contains the IP and reason from templating" + assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "IP: 1.2.3.4, reason: LAPI, trace: 0123456789" -H "X-Forwarded-For: 1.2.3.4" -H "X-Trace: 0123456789" + 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" } diff --git a/tests/e2e/mock/scenarios/redis/dynamic.yml b/tests/e2e/mock/scenarios/redis/dynamic.yml new file mode 100644 index 00000000..317916b0 --- /dev/null +++ b/tests/e2e/mock/scenarios/redis/dynamic.yml @@ -0,0 +1,30 @@ +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" + crowdsecMode: live + crowdsecLapiScheme: http + crowdsecLapiHost: "@@LAPI_HOST@@" + crowdsecLapiKey: "@@APIKEY@@" + redisCacheEnabled: "true" + redisCacheHost: "@@REDIS_HOST@@" + redisCacheReadHosts: + - "@@REDIS_READ_HOST@@" + - "@@REDIS_HOST@@" + forwardedHeadersTrustedIps: + - "127.0.0.1/32" diff --git a/tests/e2e/mock/scenarios/redis/run.sh b/tests/e2e/mock/scenarios/redis/run.sh new file mode 100644 index 00000000..65b0c890 --- /dev/null +++ b/tests/e2e/mock/scenarios/redis/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=redis + +# The replica mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5. +# The primary mock always misses. +body() { + echo "[$SCENARIO] cached banned IP must not be blocked because call for primary (test rotation)" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.5" + + echo "[$SCENARIO] cached banned IP must be blocked" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.5" + + echo "[$SCENARIO] cached clean IP must pass" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" + + echo "[$SCENARIO] unknown IP (redis miss) must fall through to LAPI and pass" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.6" +} + +run_scenario "$SCENARIO" "$HERE" body diff --git a/version.go b/version.go index 03eb0e0d..b2bcb053 100644 --- a/version.go +++ b/version.go @@ -1,4 +1,5 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck -// pluginVersion is updated automatically by the release workflow. -var pluginVersion = "1.6.X" //nolint:gochecknoglobals +// pluginVersion is what the plugin reports to the Crowdsec LAPI. +// Do not edit by hand: the "Release (1/2) Prepare" workflow bumps it. +var pluginVersion = "v1.7.1" //nolint:gochecknoglobals