mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8497f7de75 | ||
|
|
070a82992a | ||
|
|
c91ff6cc59 | ||
|
|
f6b3983299 | ||
|
|
b67edfe308 | ||
|
|
fcebbfe902 | ||
|
|
8580a085b9 | ||
|
|
b201143844 | ||
|
|
33def87c30 | ||
|
|
d1bdd7b423 | ||
|
|
45abab69ec | ||
|
|
4c3bbf81fd | ||
|
|
900c7ebdc2 |
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
name: e2e (binary + mock LAPI)
|
name: e2e (binary + mock LAPI)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: make -k e2e_mock
|
run: make -k e2e_mock
|
||||||
- name: Upload logs on failure
|
- name: Upload logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: e2e-logs
|
name: e2e-logs
|
||||||
path: /tmp/e2e-mock-*.log
|
path: /tmp/e2e-mock-*.log
|
||||||
|
|||||||
@@ -15,13 +15,8 @@ jobs:
|
|||||||
name: Main Process
|
name: Main Process
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
# Keep in sync with go.mod. Capped at 1.22 because the plugin is run by
|
GO_VERSION: 1.23
|
||||||
# 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
|
GOLANGCI_LINT_VERSION: v1.63.4
|
||||||
# yaegi_test guard — pin to the version current Traefik bundles.
|
|
||||||
YAEGI_VERSION: v0.16.1
|
YAEGI_VERSION: v0.16.1
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
defaults:
|
defaults:
|
||||||
@@ -38,14 +33,14 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/marketplace/actions/checkout
|
# https://github.com/marketplace/actions/checkout
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: go/src/github.com/${{ github.repository }}
|
path: go/src/github.com/${{ github.repository }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# https://github.com/marketplace/actions/cache
|
# https://github.com/marketplace/actions/cache
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v6
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/go/pkg/mod
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
name: Renovate
|
|
||||||
|
|
||||||
# Self-hosted Renovate: opens dependency-update PRs on a weekly 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 * * 1" # every Monday 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.14
|
|
||||||
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' }}
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ linters-settings:
|
|||||||
disable:
|
disable:
|
||||||
- fieldalignment
|
- fieldalignment
|
||||||
gocyclo:
|
gocyclo:
|
||||||
min-complexity: 20
|
min-complexity: 15
|
||||||
goconst:
|
goconst:
|
||||||
min-len: 5
|
min-len: 5
|
||||||
min-occurrences: 4
|
min-occurrences: 4
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export GO111MODULE=on
|
|||||||
|
|
||||||
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
# Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs.
|
||||||
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
# The local Docker suite (make e2e) lives in a separate PR/branch.
|
||||||
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec tls-system-ca
|
E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec
|
||||||
|
|
||||||
default: lint test
|
default: lint test
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a Crowdsec Decision ?
|
||||||
destroy CrowdsecLAPI
|
Destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -82,9 +82,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
destroy CrowdsecLAPI
|
Destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -105,10 +105,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
destroy CrowdsecLAPI
|
Destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
CrowdsecLAPI-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
@@ -125,10 +125,10 @@ sequenceDiagram
|
|||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
create participant CrowdsecLAPI
|
create participant CrowdsecLAPI
|
||||||
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>CrowdsecLAPI: Does the User IP has a crowdsec decision ?
|
||||||
destroy CrowdsecLAPI
|
Destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
CrowdsecLAPI-->>TraefikPlugin: Nothing, all good!
|
||||||
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
TraefikPlugin-->>PluginCache: Store the information for this IP for DefaultDecisionSeconds
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
@@ -145,11 +145,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecLAPI
|
participant CrowdsecLAPI
|
||||||
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
TraefikPlugin->>CrowdsecLAPI: What are the current decisions
|
||||||
destroy CrowdsecLAPI
|
Destroy CrowdsecLAPI
|
||||||
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
CrowdsecLAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,9 +162,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,9 +177,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -195,11 +195,11 @@ sequenceDiagram
|
|||||||
participant TraefikPlugin
|
participant TraefikPlugin
|
||||||
participant CrowdsecCAPI
|
participant CrowdsecCAPI
|
||||||
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
TraefikPlugin->>CrowdsecCAPI: What are the current decisions from CAPI
|
||||||
destroy CrowdsecCAPI
|
Destroy CrowdsecCAPI
|
||||||
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
CrowdsecCAPI->>TraefikPlugin: Here is the list
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Store this list
|
TraefikPlugin-->>PluginCache: Store this list
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,9 +212,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban decision
|
PluginCache-->>TraefikPlugin: Yes a ban decision
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,9 +227,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a crowdsec decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -247,9 +247,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
destroy CrowdsecAppSec
|
Destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
CrowdsecAppSec-->>TraefikPlugin: Yes I think so
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -262,9 +262,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant CrowdsecAppSec
|
create participant CrowdsecAppSec
|
||||||
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
TraefikPlugin-->>CrowdsecAppSec: Is this request malicious ?
|
||||||
destroy CrowdsecAppSec
|
Destroy CrowdsecAppSec
|
||||||
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
CrowdsecAppSec-->>TraefikPlugin: No I don't think so
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -285,12 +285,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
destroy ProviderCaptcha
|
Destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -362,7 +362,7 @@ make run
|
|||||||
- CrowdsecAppsecTlsCertificateAuthority
|
- CrowdsecAppsecTlsCertificateAuthority
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- PEM-encoded Certificate Authority used to verify Appsec's server certificate. When empty (and `crowdsecAppsecTlsInsecureVerify` is `false`), the host's system trust store is used.
|
- PEM-encoded Certificate Authority of Appsec
|
||||||
- CrowdsecAppsecScheme
|
- CrowdsecAppsecScheme
|
||||||
- string
|
- string
|
||||||
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||||
@@ -382,10 +382,6 @@ make run
|
|||||||
- int64
|
- int64
|
||||||
- default: 10485760 (= 10MB)
|
- default: 10485760 (= 10MB)
|
||||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||||
- CrowdsecAppsecUnreadableBodyBlock
|
|
||||||
- bool
|
|
||||||
- default: false
|
|
||||||
- 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` (default) 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
|
- CrowdsecAppsecKey
|
||||||
- string
|
- string
|
||||||
- default: value of `CrowdsecLapiKey`
|
- default: value of `CrowdsecLapiKey`
|
||||||
@@ -412,7 +408,7 @@ make run
|
|||||||
- CrowdsecLapiTlsCertificateAuthority
|
- CrowdsecLapiTlsCertificateAuthority
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- PEM-encoded Certificate Authority used to verify the LAPI's server certificate. When empty (and `crowdsecLapiTlsInsecureVerify` is `false`), the host's system trust store is used.
|
- PEM-encoded Certificate Authority of the Crowdsec LAPI
|
||||||
- CrowdsecLapiTlsCertificateBouncer
|
- CrowdsecLapiTlsCertificateBouncer
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -517,14 +513,14 @@ make run
|
|||||||
- int64
|
- int64
|
||||||
- default: 1800 (= 30 minutes)
|
- default: 1800 (= 30 minutes)
|
||||||
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
|
||||||
- CaptchaFilePath
|
- CaptchaHTMLFilePath
|
||||||
- string
|
- string
|
||||||
- default: /captcha.html
|
- default: /captcha.html
|
||||||
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
- Path where the captcha template is stored
|
||||||
- BanFilePath
|
- BanHTMLFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
|
- Path where the ban html file is stored (default empty ""=disabled)
|
||||||
- TraceHeadersCustomName
|
- TraceHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
@@ -620,7 +616,6 @@ http:
|
|||||||
crowdsecAppsecFailureBlock: true
|
crowdsecAppsecFailureBlock: true
|
||||||
crowdsecAppsecUnreachableBlock: true
|
crowdsecAppsecUnreachableBlock: true
|
||||||
crowdsecAppsecBodyLimit: 10485760
|
crowdsecAppsecBodyLimit: 10485760
|
||||||
crowdsecAppsecUnreadableBodyBlock: false
|
|
||||||
crowdsecLapiKey: privateKey-foo
|
crowdsecLapiKey: privateKey-foo
|
||||||
crowdsecLapiScheme: http
|
crowdsecLapiScheme: http
|
||||||
crowdsecLapiHost: crowdsec:8080
|
crowdsecLapiHost: crowdsec:8080
|
||||||
@@ -732,18 +727,16 @@ A script is available to generate certificates in `examples/tls-auth/gencerts.sh
|
|||||||
|
|
||||||
#### Use HTTPS to communicate with the LAPI
|
#### Use HTTPS to communicate with the LAPI
|
||||||
|
|
||||||
Set `crowdsecLapiScheme` to `https`. The plugin then validates Crowdsec's server certificate. Three options:
|
To communicate with the LAPI in HTTPS you need to either accept any certificates by setting the `crowdsecLapiTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecLapiTLSCertificateAuthority` or `crowdsecLapiTLSCertificateAuthorityFile`.
|
||||||
|
Set the `crowdsecLapiScheme` to https.
|
||||||
- **Publicly trusted certificate** (e.g. Let's Encrypt behind a reverse proxy): leave `crowdsecLapiTLSCertificateAuthority` empty and `crowdsecLapiTLSInsecureVerify` `false`. The plugin falls back to the host's system trust store (the `traefik` image ships `ca-certificates`).
|
|
||||||
- **Private/self-signed CA**: set `crowdsecLapiTLSCertificateAuthority` (or `…File`) to the PEM-encoded CA that signed Crowdsec's server cert.
|
|
||||||
- **Skip verification entirely** (not recommended for production): set `crowdsecLapiTLSInsecureVerify` to `true`.
|
|
||||||
|
|
||||||
Crowdsec must be listening in HTTPS for this to work.
|
Crowdsec must be listening in HTTPS for this to work.
|
||||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||||
|
|
||||||
#### Use HTTPS to communicate with the Appsec
|
#### Use HTTPS to communicate with the Appsec
|
||||||
|
|
||||||
Set `crowdsecAppsecScheme` to `https`. Same three options as for the LAPI, prefixed `crowdsecAppsec…` instead of `crowdsecLapi…`: empty CA + secure verify falls back to the system trust store, a custom CA pins to your private PKI, and `crowdsecAppsecTLSInsecureVerify=true` skips verification altogether.
|
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
|
||||||
|
Set the `crowdsecAppsecScheme` to https.
|
||||||
|
|
||||||
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||||
|
|
||||||
|
|||||||
+82
-113
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
htmltemplate "html/template"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -83,59 +84,49 @@ type Bouncer struct {
|
|||||||
name string
|
name string
|
||||||
template *template.Template
|
template *template.Template
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
appsecEnabled bool
|
appsecEnabled bool
|
||||||
appsecScheme string
|
appsecScheme string
|
||||||
appsecHost string
|
appsecHost string
|
||||||
appsecPath string
|
appsecPath string
|
||||||
appsecKey string
|
appsecKey string
|
||||||
appsecFailureBlock bool
|
appsecFailureBlock bool
|
||||||
appsecUnreachableBlock bool
|
appsecUnreachableBlock bool
|
||||||
appsecUnreadableBodyBlock bool
|
appsecBodyLimit int64
|
||||||
appsecBodyLimit int64
|
crowdsecScheme string
|
||||||
crowdsecScheme string
|
crowdsecHost string
|
||||||
crowdsecHost string
|
crowdsecPath string
|
||||||
crowdsecPath string
|
crowdsecKey string
|
||||||
crowdsecKey string
|
crowdsecMode string
|
||||||
crowdsecMode string
|
crowdsecMachineID string
|
||||||
crowdsecMachineID string
|
crowdsecPassword string
|
||||||
crowdsecPassword string
|
crowdsecScenarios []string
|
||||||
crowdsecScenarios []string
|
updateInterval int64
|
||||||
updateInterval int64
|
updateMaxFailure int64
|
||||||
updateMaxFailure int64
|
defaultDecisionTimeout int64
|
||||||
defaultDecisionTimeout int64
|
remediationStatusCode int
|
||||||
remediationStatusCode int
|
remediationCustomHeader string
|
||||||
remediationCustomHeader string
|
forwardedCustomHeader string
|
||||||
forwardedCustomHeader string
|
crowdsecStreamRoute string
|
||||||
crowdsecStreamRoute string
|
crowdsecHeader string
|
||||||
crowdsecHeader string
|
redisUnreachableBlock bool
|
||||||
redisUnreachableBlock bool
|
banTemplate *htmltemplate.Template
|
||||||
banTemplate *template.Template
|
traceCustomHeader string
|
||||||
banTemplateContentType string
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
traceCustomHeader string
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
httpClient *http.Client
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
httpAppsecClient *http.Client
|
||||||
httpClient *http.Client
|
cacheClient *cache.Client
|
||||||
httpAppsecClient *http.Client
|
captchaClient *captcha.Client
|
||||||
cacheClient *cache.Client
|
log *slog.Logger
|
||||||
captchaClient *captcha.Client
|
|
||||||
log *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
//
|
//
|
||||||
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
//nolint:nestif,gocyclo,gocognit
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
||||||
config.LogLevel = strings.ToUpper(config.LogLevel)
|
config.LogLevel = strings.ToUpper(config.LogLevel)
|
||||||
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
|
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)
|
err := configuration.ValidateParams(config, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("New:validateParams " + err.Error())
|
log.Error("New:validateParams " + err.Error())
|
||||||
@@ -193,10 +184,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplate *template.Template
|
var banTemplate *htmltemplate.Template
|
||||||
var banTemplateContentType string
|
if config.BanHTMLFilePath != "" {
|
||||||
if config.BanFilePath != "" {
|
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||||
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bouncer := &Bouncer{
|
bouncer := &Bouncer{
|
||||||
@@ -204,37 +194,35 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
name: name,
|
name: name,
|
||||||
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
|
||||||
|
|
||||||
enabled: config.Enabled,
|
enabled: config.Enabled,
|
||||||
crowdsecMode: config.CrowdsecMode,
|
crowdsecMode: config.CrowdsecMode,
|
||||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||||
appsecScheme: config.CrowdsecAppsecScheme,
|
appsecScheme: config.CrowdsecAppsecScheme,
|
||||||
appsecHost: config.CrowdsecAppsecHost,
|
appsecHost: config.CrowdsecAppsecHost,
|
||||||
appsecPath: config.CrowdsecAppsecPath,
|
appsecPath: config.CrowdsecAppsecPath,
|
||||||
appsecKey: config.CrowdsecAppsecKey,
|
appsecKey: config.CrowdsecAppsecKey,
|
||||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||||
appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
|
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
crowdsecScheme: config.CrowdsecLapiScheme,
|
||||||
crowdsecScheme: config.CrowdsecLapiScheme,
|
crowdsecHost: config.CrowdsecLapiHost,
|
||||||
crowdsecHost: config.CrowdsecLapiHost,
|
crowdsecPath: config.CrowdsecLapiPath,
|
||||||
crowdsecPath: config.CrowdsecLapiPath,
|
crowdsecKey: config.CrowdsecLapiKey,
|
||||||
crowdsecKey: config.CrowdsecLapiKey,
|
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
||||||
crowdsecMachineID: config.CrowdsecCapiMachineID,
|
crowdsecPassword: config.CrowdsecCapiPassword,
|
||||||
crowdsecPassword: config.CrowdsecCapiPassword,
|
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
||||||
crowdsecScenarios: config.CrowdsecCapiScenarios,
|
updateInterval: config.UpdateIntervalSeconds,
|
||||||
updateInterval: config.UpdateIntervalSeconds,
|
updateMaxFailure: config.UpdateMaxFailure,
|
||||||
updateMaxFailure: config.UpdateMaxFailure,
|
remediationCustomHeader: config.RemediationHeadersCustomName,
|
||||||
remediationCustomHeader: config.RemediationHeadersCustomName,
|
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
||||||
forwardedCustomHeader: config.ForwardedHeadersCustomName,
|
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
remediationStatusCode: config.RemediationStatusCode,
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
banTemplate: banTemplate,
|
||||||
banTemplate: banTemplate,
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
banTemplateContentType: banTemplateContentType,
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
traceCustomHeader: config.TraceHeadersCustomName,
|
crowdsecHeader: crowdsecHeader,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
log: log,
|
||||||
crowdsecHeader: crowdsecHeader,
|
|
||||||
log: log,
|
|
||||||
serverPoolStrategy: &ip.PoolStrategy{
|
serverPoolStrategy: &ip.PoolStrategy{
|
||||||
Checker: serverChecker,
|
Checker: serverChecker,
|
||||||
},
|
},
|
||||||
@@ -288,7 +276,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
config.CaptchaSiteKey,
|
config.CaptchaSiteKey,
|
||||||
config.CaptchaSecretKey,
|
config.CaptchaSecretKey,
|
||||||
config.RemediationHeadersCustomName,
|
config.RemediationHeadersCustomName,
|
||||||
config.CaptchaFilePath,
|
config.CaptchaHTMLFilePath,
|
||||||
config.CaptchaGracePeriodSeconds,
|
config.CaptchaGracePeriodSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,7 +317,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
|
|
||||||
// ServeHTTP principal function of plugin.
|
// ServeHTTP principal function of plugin.
|
||||||
//
|
//
|
||||||
//nolint:nestif
|
//nolint:nestif,gocyclo
|
||||||
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
if !bouncer.enabled {
|
if !bouncer.enabled {
|
||||||
bouncer.next.ServeHTTP(rw, req)
|
bouncer.next.ServeHTTP(rw, req)
|
||||||
@@ -445,9 +433,14 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req
|
|||||||
if bouncer.remediationCustomHeader != "" {
|
if bouncer.remediationCustomHeader != "" {
|
||||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
if bouncer.banTemplate == nil {
|
||||||
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||||
if bouncer.banTemplate == nil || req.Method == http.MethodHead {
|
|
||||||
|
if req.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
templateData := map[string]string{
|
templateData := map[string]string{
|
||||||
@@ -679,12 +672,6 @@ func handleStreamCache(bouncer *Bouncer) error {
|
|||||||
return nil
|
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) {
|
func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
@@ -696,7 +683,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
|||||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpClient.Do(req)
|
res, err := bouncer.httpClient.Do(req)
|
||||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
return nil, fmt.Errorf("crowdsecQuery:unreachable url:%s %w", stringURL, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -724,17 +711,6 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.ProtoMajor >= 2 && httpReq.ContentLength < 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||||
routeURL := url.URL{
|
routeURL := url.URL{
|
||||||
Scheme: bouncer.appsecScheme,
|
Scheme: bouncer.appsecScheme,
|
||||||
@@ -742,14 +718,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
Path: bouncer.appsecPath,
|
Path: bouncer.appsecPath,
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
switch {
|
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil {
|
||||||
case isBodyUnreadable(httpReq):
|
|
||||||
if bouncer.appsecUnreadableBodyBlock {
|
|
||||||
// The caller (handleNextServeHTTP) logs this returned error with the IP.
|
|
||||||
return errors.New("appsecQuery:unreadableBody dropped")
|
|
||||||
}
|
|
||||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
|
||||||
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
|
|
||||||
var bodyBuffer bytes.Buffer
|
var bodyBuffer bytes.Buffer
|
||||||
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
|
||||||
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
teeReader := io.TeeReader(limitedReader, &bodyBuffer)
|
||||||
@@ -760,7 +729,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
// Conserve body intact after reading it for other middlewares and service
|
// Conserve body intact after reading it for other middlewares and service
|
||||||
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
|
||||||
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
|
||||||
default:
|
} else {
|
||||||
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +747,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
|||||||
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
req.Header.Set("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||||
|
|
||||||
res, err := bouncer.httpAppsecClient.Do(req)
|
res, err := bouncer.httpAppsecClient.Do(req)
|
||||||
if err != nil || isReverseProxyError(res.StatusCode) {
|
if err != nil {
|
||||||
bouncer.log.Error("appsecQuery:unreachable")
|
bouncer.log.Error("appsecQuery:unreachable")
|
||||||
if bouncer.appsecUnreachableBlock {
|
if bouncer.appsecUnreachableBlock {
|
||||||
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
return fmt.Errorf("appsecQuery:unreachable %w", err)
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ func getTestConfig() *configuration.Config {
|
|||||||
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
||||||
ForwardedHeadersCustomName: "",
|
ForwardedHeadersCustomName: "",
|
||||||
RemediationStatusCode: 403,
|
RemediationStatusCode: 403,
|
||||||
BanFilePath: "",
|
BanHTMLFilePath: "",
|
||||||
RemediationHeadersCustomName: "",
|
RemediationHeadersCustomName: "",
|
||||||
CaptchaProvider: "",
|
CaptchaProvider: "",
|
||||||
CaptchaSiteKey: "",
|
CaptchaSiteKey: "",
|
||||||
CaptchaSecretKey: "",
|
CaptchaSecretKey: "",
|
||||||
CaptchaGracePeriodSeconds: 1,
|
CaptchaGracePeriodSeconds: 1,
|
||||||
CaptchaFilePath: "",
|
CaptchaHTMLFilePath: "",
|
||||||
RedisCacheEnabled: false,
|
RedisCacheEnabled: false,
|
||||||
RedisCacheHost: "",
|
RedisCacheHost: "",
|
||||||
RedisCachePassword: "",
|
RedisCachePassword: "",
|
||||||
|
|||||||
+3
-184
@@ -2,19 +2,16 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
htmltemplate "html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
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) {
|
func TestServeHTTP(t *testing.T) {
|
||||||
@@ -193,11 +190,11 @@ func Test_crowdsecQuery(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||||
html := "<html>You are banned</html>"
|
html := "<html>You are banned</html>"
|
||||||
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
banTemplate, _ := htmltemplate.New("html").Parse(html)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
method string
|
method string
|
||||||
banTemplate *template.Template
|
banTemplate *htmltemplate.Template
|
||||||
expectBodyContent bool
|
expectBodyContent bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -238,7 +235,6 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
|||||||
remediationStatusCode: http.StatusForbidden,
|
remediationStatusCode: http.StatusForbidden,
|
||||||
remediationCustomHeader: "X-Test-Remediation",
|
remediationCustomHeader: "X-Test-Remediation",
|
||||||
banTemplate: tt.banTemplate,
|
banTemplate: tt.banTemplate,
|
||||||
banTemplateContentType: "text/html; charset=utf-8",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -273,50 +269,6 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleBanServeHTTPContentType(t *testing.T) {
|
|
||||||
html := "<html>You are banned</html>"
|
|
||||||
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) {
|
func TestCaptchaMethodBasedLogic(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -380,136 +332,3 @@ func TestCaptchaMethodBasedLogic(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
protoMajor int
|
|
||||||
contentLength int64
|
|
||||||
hasBody bool
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, hasBody: true, want: true},
|
|
||||||
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, hasBody: true, want: true},
|
|
||||||
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, hasBody: true, want: false},
|
|
||||||
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, hasBody: true, want: false},
|
|
||||||
{name: "http2 without body", protoMajor: 2, contentLength: -1, hasBody: false, 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
|
|
||||||
if tt.hasBody {
|
|
||||||
req.Body = http.NoBody
|
|
||||||
} else {
|
|
||||||
req.Body = nil
|
|
||||||
}
|
|
||||||
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() {
|
|
||||||
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
|
||||||
}()
|
|
||||||
|
|
||||||
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() {
|
|
||||||
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
|
||||||
}()
|
|
||||||
|
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ More information is available on configuring Crowdsec in the [official documenta
|
|||||||
```yaml
|
```yaml
|
||||||
...
|
...
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
volumes:
|
volumes:
|
||||||
# For captcha and ban mixed decision
|
# For captcha and ban mixed decision
|
||||||
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
- './profiles.yaml:/etc/crowdsec/profiles.yaml:ro'
|
||||||
@@ -100,9 +100,9 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Nothing, all good!
|
PluginCache-->>TraefikPlugin: Nothing, all good!
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -121,12 +121,12 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Fine, done!
|
User->>TraefikPlugin: Fine, done!
|
||||||
create participant ProviderCaptcha
|
create participant ProviderCaptcha
|
||||||
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
|
||||||
destroy ProviderCaptcha
|
Destroy ProviderCaptcha
|
||||||
ProviderCaptcha-->>TraefikPlugin: Yes
|
ProviderCaptcha-->>TraefikPlugin: Yes
|
||||||
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Done
|
PluginCache-->>TraefikPlugin: Done
|
||||||
destroy TraefikPlugin
|
Destroy TraefikPlugin
|
||||||
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
|
||||||
Webserver->>User: HTTP Response
|
Webserver->>User: HTTP Response
|
||||||
```
|
```
|
||||||
@@ -140,7 +140,7 @@ sequenceDiagram
|
|||||||
User->>TraefikPlugin: Can I access that webpage
|
User->>TraefikPlugin: Can I access that webpage
|
||||||
create participant PluginCache
|
create participant PluginCache
|
||||||
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
|
||||||
destroy PluginCache
|
Destroy PluginCache
|
||||||
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
PluginCache-->>TraefikPlugin: Yes a ban Decision
|
||||||
TraefikPlugin->>User: No, HTTP 403
|
TraefikPlugin->>User: No, HTTP 403
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
# Define ban file path
|
# Define ban HTML file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
|
The ban HTML 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.
|
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
|
```yaml
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||||
# Define ban file path
|
# Define ban HTML file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -87,7 +87,7 @@ services:
|
|||||||
- "traefik.enable=false"
|
- "traefik.enable=false"
|
||||||
|
|
||||||
redis-secure:
|
redis-secure:
|
||||||
image: "redis:8.8.0-alpine"
|
image: "redis:7.0.12-alpine"
|
||||||
container_name: "redis-secure"
|
container_name: "redis-secure"
|
||||||
hostname: redis-secure
|
hostname: redis-secure
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM ubuntu:26.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl wget
|
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
|
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
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
# Define AppSec host and port informations
|
# Define AppSec host and port informations
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:latest
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.8
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
container_name: "crowdsec"
|
container_name: "crowdsec"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ package captcha
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
|
||||||
|
|
||||||
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
||||||
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
||||||
@@ -21,8 +21,7 @@ type Client struct {
|
|||||||
secretKey string
|
secretKey string
|
||||||
remediationCustomHeader string
|
remediationCustomHeader string
|
||||||
gracePeriodSeconds int64
|
gracePeriodSeconds int64
|
||||||
templateContentType string
|
captchaTemplate *template.Template
|
||||||
template *template.Template
|
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
@@ -75,9 +74,8 @@ func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *ht
|
|||||||
c.siteKey = siteKey
|
c.siteKey = siteKey
|
||||||
c.secretKey = secretKey
|
c.secretKey = secretKey
|
||||||
c.remediationCustomHeader = remediationCustomHeader
|
c.remediationCustomHeader = remediationCustomHeader
|
||||||
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||||
c.template = template
|
c.captchaTemplate = html
|
||||||
c.templateContentType = contentType
|
|
||||||
c.gracePeriodSeconds = gracePeriodSeconds
|
c.gracePeriodSeconds = gracePeriodSeconds
|
||||||
c.log = log
|
c.log = log
|
||||||
c.httpClient = httpClient
|
c.httpClient = httpClient
|
||||||
@@ -102,12 +100,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", c.templateContentType)
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if c.remediationCustomHeader != "" {
|
if c.remediationCustomHeader != "" {
|
||||||
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
rw.Header().Set(c.remediationCustomHeader, "captcha")
|
||||||
}
|
}
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
err = c.template.Execute(rw, map[string]string{
|
err = c.captchaTemplate.Execute(rw, map[string]string{
|
||||||
"SiteKey": c.siteKey,
|
"SiteKey": c.siteKey,
|
||||||
"FrontendJS": c.infoProvider.js,
|
"FrontendJS": c.infoProvider.js,
|
||||||
"FrontendKey": c.infoProvider.key,
|
"FrontendKey": c.infoProvider.key,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -14,7 +15,6 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
|
||||||
|
|
||||||
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
||||||
)
|
)
|
||||||
@@ -63,7 +63,6 @@ type Config struct {
|
|||||||
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||||
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
|
|
||||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||||
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
|
||||||
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
|
||||||
@@ -100,10 +99,8 @@ type Config struct {
|
|||||||
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
|
||||||
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
|
||||||
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"`
|
||||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||||
BanFilePath string `json:"banFilePath,omitempty"`
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
|
||||||
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||||
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||||
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||||
@@ -128,53 +125,52 @@ func contains(source []string, target string) bool {
|
|||||||
// New creates the default plugin configuration.
|
// New creates the default plugin configuration.
|
||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LogLevel: LogINFO,
|
LogLevel: LogINFO,
|
||||||
LogFormat: "common",
|
LogFormat: "common",
|
||||||
LogFilePath: "",
|
LogFilePath: "",
|
||||||
CrowdsecMode: LiveMode,
|
CrowdsecMode: LiveMode,
|
||||||
CrowdsecAppsecEnabled: false,
|
CrowdsecAppsecEnabled: false,
|
||||||
CrowdsecAppsecFailureBlock: true,
|
CrowdsecAppsecFailureBlock: true,
|
||||||
CrowdsecAppsecUnreachableBlock: true,
|
CrowdsecAppsecUnreachableBlock: true,
|
||||||
CrowdsecAppsecUnreadableBodyBlock: true,
|
CrowdsecAppsecBodyLimit: 10485760,
|
||||||
CrowdsecAppsecBodyLimit: 10485760,
|
CrowdsecAppsecScheme: "",
|
||||||
CrowdsecAppsecScheme: "",
|
CrowdsecAppsecHost: "crowdsec:7422",
|
||||||
CrowdsecAppsecHost: "crowdsec:7422",
|
CrowdsecAppsecPath: "/",
|
||||||
CrowdsecAppsecPath: "/",
|
CrowdsecAppsecKey: "",
|
||||||
CrowdsecAppsecKey: "",
|
CrowdsecAppsecTLSInsecureVerify: false,
|
||||||
CrowdsecAppsecTLSInsecureVerify: false,
|
CrowdsecLapiScheme: HTTP,
|
||||||
CrowdsecLapiScheme: HTTP,
|
CrowdsecLapiHost: "crowdsec:8080",
|
||||||
CrowdsecLapiHost: "crowdsec:8080",
|
CrowdsecLapiPath: "/",
|
||||||
CrowdsecLapiPath: "/",
|
CrowdsecLapiKey: "",
|
||||||
CrowdsecLapiKey: "",
|
CrowdsecLapiTLSInsecureVerify: false,
|
||||||
CrowdsecLapiTLSInsecureVerify: false,
|
UpdateIntervalSeconds: 60,
|
||||||
UpdateIntervalSeconds: 60,
|
MetricsUpdateIntervalSeconds: 600,
|
||||||
MetricsUpdateIntervalSeconds: 600,
|
UpdateMaxFailure: 0,
|
||||||
UpdateMaxFailure: 0,
|
StreamStartupBlock: true,
|
||||||
StreamStartupBlock: true,
|
DefaultDecisionSeconds: 60,
|
||||||
DefaultDecisionSeconds: 60,
|
RemediationStatusCode: http.StatusForbidden,
|
||||||
RemediationStatusCode: http.StatusForbidden,
|
HTTPTimeoutSeconds: 10,
|
||||||
HTTPTimeoutSeconds: 10,
|
CaptchaProvider: "",
|
||||||
CaptchaProvider: "",
|
CaptchaCustomJsURL: "",
|
||||||
CaptchaCustomJsURL: "",
|
CaptchaCustomValidateURL: "",
|
||||||
CaptchaCustomValidateURL: "",
|
CaptchaCustomKey: "",
|
||||||
CaptchaCustomKey: "",
|
CaptchaCustomResponse: "",
|
||||||
CaptchaCustomResponse: "",
|
CaptchaSiteKey: "",
|
||||||
CaptchaSiteKey: "",
|
CaptchaSecretKey: "",
|
||||||
CaptchaSecretKey: "",
|
CaptchaGracePeriodSeconds: 1800,
|
||||||
CaptchaGracePeriodSeconds: 1800,
|
CaptchaHTMLFilePath: "/captcha.html",
|
||||||
CaptchaFilePath: "/captcha.html",
|
BanHTMLFilePath: "",
|
||||||
BanFilePath: "",
|
TraceHeadersCustomName: "",
|
||||||
TraceHeadersCustomName: "",
|
RemediationHeadersCustomName: "",
|
||||||
RemediationHeadersCustomName: "",
|
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
ForwardedHeadersTrustedIPs: []string{},
|
||||||
ForwardedHeadersTrustedIPs: []string{},
|
ClientTrustedIPs: []string{},
|
||||||
ClientTrustedIPs: []string{},
|
RedisCacheEnabled: false,
|
||||||
RedisCacheEnabled: false,
|
RedisCacheHost: "redis:6379",
|
||||||
RedisCacheHost: "redis:6379",
|
RedisCachePassword: "",
|
||||||
RedisCachePassword: "",
|
RedisCacheDatabase: "",
|
||||||
RedisCacheDatabase: "",
|
RedisCacheUnreachableBlock: true,
|
||||||
RedisCacheUnreachableBlock: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,50 +201,28 @@ func GetVariable(config *Config, key string) (string, error) {
|
|||||||
return strings.TrimSpace(value), nil
|
return strings.TrimSpace(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContentTypeFromPath(path string) string {
|
// GetHTMLTemplate get compiled HTML template.
|
||||||
|
func GetHTMLTemplate(path string) (*template.Template, error) {
|
||||||
|
var err error
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return nil, errors.New("no html template provided")
|
||||||
}
|
}
|
||||||
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
|
//nolint:gosec
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
content := string(b)
|
html := string(b)
|
||||||
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
compiledTemplate, err := template.New("html").Parse(html)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
return nil, fmt.Errorf("impossible to compile html template: %w", err)
|
||||||
}
|
}
|
||||||
return compiledTemplate, contentType, nil
|
return compiledTemplate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateParams validate all the param gave by user.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit,nestif
|
//nolint:gocyclo,gocognit
|
||||||
func ValidateParams(config *Config, log *slog.Logger) error {
|
func ValidateParams(config *Config, log *slog.Logger) error {
|
||||||
if err := validateParamsRequired(config); err != nil {
|
if err := validateParamsRequired(config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -286,14 +260,12 @@ func ValidateParams(config *Config, log *slog.Logger) error {
|
|||||||
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.CaptchaFilePath != "" {
|
if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
||||||
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.BanFilePath != "" {
|
if config.BanHTMLFilePath != "" {
|
||||||
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,8 +361,7 @@ func validateParamsTLS(config *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if certAuth == "" {
|
if certAuth == "" {
|
||||||
// No custom CA — runtime will fall back to the system trust store.
|
return errors.New("CrowdsecLapiTLSCertificateAuthority must be specified when CrowdsecLapiScheme='https' and CrowdsecLapiTLSInsecureVerify=false")
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
@@ -482,31 +453,29 @@ func validateParamsRequired(config *Config) error {
|
|||||||
|
|
||||||
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
func getTLSConfig(config *Config, log *slog.Logger, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||||
tlsConfig := new(tls.Config)
|
tlsConfig := new(tls.Config)
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
if scheme != HTTPS {
|
if scheme != HTTPS {
|
||||||
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
log.Debug("getTLSConfig:" + prefix + "Scheme https:no")
|
||||||
return tlsConfig, nil
|
return tlsConfig, nil
|
||||||
}
|
}
|
||||||
// RootCAs is intentionally left nil unless a custom CA is provided:
|
|
||||||
// crypto/tls then falls back to x509.SystemCertPool(), which is what we
|
|
||||||
// want when the LAPI is exposed behind a reverse proxy with a publicly
|
|
||||||
// trusted certificate (e.g. Let's Encrypt).
|
|
||||||
//nolint:nestif
|
//nolint:nestif
|
||||||
if insecureVerify {
|
if insecureVerify {
|
||||||
tlsConfig.InsecureSkipVerify = true
|
tlsConfig.InsecureSkipVerify = true
|
||||||
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
log.Debug("getTLSConfig:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||||
|
// If we return here and still want to use client auth this won't work
|
||||||
|
// return tlsConfig, nil
|
||||||
} else {
|
} else {
|
||||||
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(certAuthority) > 0 {
|
if len(certAuthority) > 0 {
|
||||||
tlsConfig.RootCAs = x509.NewCertPool()
|
|
||||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||||
|
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||||
|
// and CA not load, we can't communicate with https
|
||||||
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
return nil, errors.New("getTLSConfig:" + prefix + " cannot load CA and verify cert is enabled")
|
||||||
}
|
}
|
||||||
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
log.Debug("getTLSConfig:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||||
} else {
|
|
||||||
log.Debug("getTLSConfig:" + prefix + " no CA provided, using system trust store")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validPEM is a minimal self-signed certificate accepted by AppendCertsFromPEM,
|
|
||||||
// shared by the TLS tests below.
|
|
||||||
const validPEM = `-----BEGIN CERTIFICATE-----
|
|
||||||
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
|
||||||
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
|
||||||
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
|
||||||
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
|
||||||
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
|
||||||
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
|
||||||
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
|
||||||
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
|
||||||
6MF9+Yw1Yy0t
|
|
||||||
-----END CERTIFICATE-----`
|
|
||||||
|
|
||||||
func getMinimalConfig() *Config {
|
func getMinimalConfig() *Config {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
cfg.CrowdsecLapiKey = "test"
|
cfg.CrowdsecLapiKey = "test"
|
||||||
@@ -123,7 +111,7 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
{name: "Not validate a bad clients ips", args: args{config: cfg5}, wantErr: true},
|
||||||
// HTTPS enabled
|
// HTTPS enabled
|
||||||
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
{name: "Validate https config with insecure verify", args: args{config: cfg6}, wantErr: false},
|
||||||
{name: "Validate https without cert authority (falls back to system trust store)", args: args{config: cfg7}, wantErr: false},
|
{name: "Not validate https without cert authority", args: args{config: cfg7}, wantErr: true},
|
||||||
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
{name: "Valid log level uppercase INFO", args: args{config: cfg8}, wantErr: false},
|
||||||
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
{name: "Valid log level lowercase info", args: args{config: cfg9}, wantErr: false},
|
||||||
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
{name: "Invalid log level Warning", args: args{config: cfg10}, wantErr: true},
|
||||||
@@ -138,24 +126,19 @@ func Test_ValidateParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_validateParamsTLS(t *testing.T) {
|
func Test_validateParamsTLS(t *testing.T) {
|
||||||
cfgEmpty := getMinimalConfig()
|
type args struct {
|
||||||
cfgValid := getMinimalConfig()
|
config *Config
|
||||||
cfgValid.CrowdsecLapiTLSCertificateAuthority = validPEM
|
}
|
||||||
cfgInvalidCA := getMinimalConfig()
|
|
||||||
cfgInvalidCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *Config
|
args args
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "Empty CA is accepted (system trust store used at runtime)", config: cfgEmpty, wantErr: false},
|
// TODO: Add test cases.
|
||||||
{name: "Valid PEM CA is accepted", config: cfgValid, wantErr: false},
|
|
||||||
{name: "Invalid CA is rejected", config: cfgInvalidCA, wantErr: true},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := validateParamsTLS(tt.config); (err != nil) != tt.wantErr {
|
if err := validateParamsTLS(tt.args.config); (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateParamsTLS() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,78 +233,26 @@ func Test_validateParamsAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||||
log := logger.New("INFO", "")
|
log := logger.New("INFO", "")
|
||||||
|
type args struct {
|
||||||
httpCfg := getMinimalConfig()
|
config *Config
|
||||||
httpCfg.CrowdsecLapiScheme = HTTP
|
}
|
||||||
|
|
||||||
httpsSystemCA := getMinimalConfig()
|
|
||||||
httpsSystemCA.CrowdsecLapiScheme = HTTPS
|
|
||||||
|
|
||||||
httpsCustomCA := getMinimalConfig()
|
|
||||||
httpsCustomCA.CrowdsecLapiScheme = HTTPS
|
|
||||||
httpsCustomCA.CrowdsecLapiTLSCertificateAuthority = validPEM
|
|
||||||
|
|
||||||
httpsInsecure := getMinimalConfig()
|
|
||||||
httpsInsecure.CrowdsecLapiScheme = HTTPS
|
|
||||||
httpsInsecure.CrowdsecLapiTLSInsecureVerify = true
|
|
||||||
|
|
||||||
httpsBadCA := getMinimalConfig()
|
|
||||||
httpsBadCA.CrowdsecLapiScheme = HTTPS
|
|
||||||
httpsBadCA.CrowdsecLapiTLSCertificateAuthority = "not a pem"
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *Config
|
args args
|
||||||
wantErr bool
|
want *tls.Config
|
||||||
wantRootCAsNil bool
|
wantErr bool
|
||||||
wantInsecureSkip bool
|
|
||||||
}{
|
}{
|
||||||
{name: "HTTP scheme returns empty tls.Config", config: httpCfg, wantRootCAsNil: true},
|
// TODO: Add test cases.
|
||||||
{name: "HTTPS without CA leaves RootCAs nil (system trust store)", config: httpsSystemCA, wantRootCAsNil: true},
|
|
||||||
{name: "HTTPS with custom CA populates RootCAs", config: httpsCustomCA, wantRootCAsNil: false},
|
|
||||||
{name: "HTTPS with insecure verify sets InsecureSkipVerify", config: httpsInsecure, wantRootCAsNil: true, wantInsecureSkip: true},
|
|
||||||
{name: "HTTPS with garbage CA is rejected", config: httpsBadCA, wantErr: true},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := GetTLSConfigCrowdsec(tt.config, log, false)
|
got, err := GetTLSConfigCrowdsec(tt.args.config, log, false)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("GetTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tt.wantErr {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
return
|
t.Errorf("getTLSConfigCrowdsec() = %v, want %v", got, tt.want)
|
||||||
}
|
|
||||||
if (got.RootCAs == nil) != tt.wantRootCAsNil {
|
|
||||||
t.Errorf("GetTLSConfigCrowdsec() RootCAs nil = %v, want nil = %v", got.RootCAs == nil, tt.wantRootCAsNil)
|
|
||||||
}
|
|
||||||
if got.InsecureSkipVerify != tt.wantInsecureSkip {
|
|
||||||
t.Errorf("GetTLSConfigCrowdsec() InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, tt.wantInsecureSkip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"$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=(?<currentValue>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*\"(?<currentValue>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*(?<currentValue>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*(?<currentValue>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:-(?<currentValue>v[0-9]+\\.[0-9]+\\.[0-9]+)"
|
|
||||||
],
|
|
||||||
"depNameTemplate": "traefik/traefik",
|
|
||||||
"datasourceTemplate": "github-releases"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ that lives upstream in Crowdsec.
|
|||||||
|-----------|-----|
|
|-----------|-----|
|
||||||
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
|
| Traefik | Binary `v3.7.1`, downloaded into `.cache/` (reused across local runs; re-downloaded on fresh CI runners) |
|
||||||
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
|
| Plugin | Loaded via `experimental.localPlugins` from the repo root (symlinked into `plugins-local/`) |
|
||||||
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli`. Serves plain HTTP, or HTTPS when `--lapi-tls-cert/--lapi-tls-key` are passed (the `tls-system-ca` scenario) |
|
| LAPI | `mocklapi` — a stdlib-only Go command (its own nested module), compiled and cached under `.cache/`, driven through `/admin` endpoints instead of `cscli` |
|
||||||
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
|
| AppSec | WAF stand-in built into the mock — blocks URIs containing `rpc2`, allows the rest |
|
||||||
| Backend | A plain HTTP responder built into the mock |
|
| Backend | A plain HTTP responder built into the mock |
|
||||||
|
|
||||||
@@ -39,16 +39,9 @@ backend `8091`, AppSec `8092`.
|
|||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
Prerequisites: `bash`, `curl`, `go`, `tar` (plus `openssl` for the
|
Prerequisites: `bash`, `curl`, `go`, `tar`. On first use the Traefik binary is
|
||||||
`tls-system-ca` scenario, which mints a throwaway CA at runtime). On first use
|
fetched and the mock is compiled into `.cache/`. That cache is reused across
|
||||||
the Traefik binary is fetched and the mock is compiled into `.cache/`. That
|
local runs; CI runs on fresh runners, so both are recreated on every CI run.
|
||||||
cache is reused across local runs; CI runs on fresh runners, so both are
|
|
||||||
recreated on every CI run.
|
|
||||||
|
|
||||||
The `tls-system-ca` scenario verifies that, with no custom CA configured, the
|
|
||||||
bouncer falls back to the OS/system trust store for an HTTPS LAPI: it serves the
|
|
||||||
mock over TLS and points the Traefik process's `SSL_CERT_FILE` at the test CA
|
|
||||||
(trusted → 200) or an empty bundle (untrusted → 403, proving it still verifies).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one scenario
|
# one scenario
|
||||||
|
|||||||
@@ -190,29 +190,16 @@ start_stack() {
|
|||||||
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
|
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
|
||||||
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
|
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
|
||||||
|
|
||||||
# Opt-in HTTPS LAPI: a scenario exports LAPI_TLS_CERT/LAPI_TLS_KEY to serve the
|
|
||||||
# LAPI over TLS (used by tls-system-ca). Default empty -> plaintext as before.
|
|
||||||
local mock_tls_args=() lapi_scheme=http lapi_curl=()
|
|
||||||
if [[ -n "${LAPI_TLS_CERT:-}" && -n "${LAPI_TLS_KEY:-}" ]]; then
|
|
||||||
mock_tls_args=(--lapi-tls-cert "$LAPI_TLS_CERT" --lapi-tls-key "$LAPI_TLS_KEY")
|
|
||||||
lapi_scheme=https
|
|
||||||
lapi_curl=(-k) # the readiness probe ignores trust; the bouncer's trust is what we test
|
|
||||||
fi
|
|
||||||
|
|
||||||
"$mock_bin" \
|
"$mock_bin" \
|
||||||
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
|
--lapi-addr "127.0.0.1:${LAPI_PORT}" \
|
||||||
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
|
--backend-addr "127.0.0.1:${BACKEND_PORT}" \
|
||||||
--appsec-addr "127.0.0.1:${APPSEC_PORT}" \
|
--appsec-addr "127.0.0.1:${APPSEC_PORT}" >"$WORKDIR/mock.log" 2>&1 &
|
||||||
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
|
|
||||||
MOCK_PID=$!
|
MOCK_PID=$!
|
||||||
|
|
||||||
# Opt-in trust store for the Traefik process: a scenario exports
|
( cd "$WORKDIR" && exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
||||||
# TRAEFIK_SSL_CERT_FILE to point Go's x509.SystemCertPool() at a specific CA
|
|
||||||
# bundle. Empty -> Go's default system store (unchanged behaviour).
|
|
||||||
( cd "$WORKDIR" && SSL_CERT_FILE="${TRAEFIK_SSL_CERT_FILE:-}" exec "$traefik_bin" --configfile=traefik.yml ) >"$WORKDIR/traefik.log" 2>&1 &
|
|
||||||
TRAEFIK_PID=$!
|
TRAEFIK_PID=$!
|
||||||
|
|
||||||
wait_for_status "${lapi_scheme}://127.0.0.1:${LAPI_PORT}/health" 200 30 "${lapi_curl[@]}"
|
wait_for_status "http://127.0.0.1:${LAPI_PORT}/health" 200 30
|
||||||
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
|
# AppSec stand-in: a bare GET carries no "rpc2" URI, so it answers 200 (allow).
|
||||||
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
|
wait_for_status "http://127.0.0.1:${APPSEC_PORT}/" 200 30
|
||||||
# /ping is served by Traefik itself once it is up (plugin compilation included).
|
# /ping is served by Traefik itself once it is up (plugin compilation included).
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -53,11 +52,6 @@ func main() {
|
|||||||
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
|
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
|
||||||
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
|
// 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")
|
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
|
||||||
// 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.
|
|
||||||
lapiTLSCert := flag.String("lapi-tls-cert", "", "PEM cert to serve the LAPI over HTTPS (optional)")
|
|
||||||
lapiTLSKey := flag.String("lapi-tls-key", "", "PEM key for --lapi-tls-cert")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -73,26 +67,9 @@ func main() {
|
|||||||
// exercised without standing up the real WAF.
|
// exercised without standing up the real WAF.
|
||||||
go func() {
|
go func() {
|
||||||
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
log.Fatal(http.ListenAndServe(*appsecAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "403") {
|
if strings.Contains(r.Header.Get("X-Crowdsec-Appsec-Uri"), "rpc2") {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
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
|
|
||||||
}
|
|
||||||
})))
|
})))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -153,10 +130,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if *lapiTLSCert != "" && *lapiTLSKey != "" {
|
|
||||||
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr)
|
|
||||||
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", *lapiAddr, *backendAddr, *appsecAddr)
|
||||||
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
|
log.Fatal(http.ListenAndServe(*lapiAddr, mux))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ http:
|
|||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
crowdsecLapiHost: "@@LAPI_HOST@@"
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
crowdsecAppsecEnabled: "true"
|
crowdsecAppsecEnabled: "true"
|
||||||
crowdsecAppsecFailureBlock: "true"
|
|
||||||
crowdsecAppsecBodyLimit: 4
|
|
||||||
crowdsecAppsecUnreachableBlock: "false"
|
|
||||||
crowdsecAppsecScheme: http
|
crowdsecAppsecScheme: http
|
||||||
crowdsecAppsecHost: "@@APPSEC_HOST@@"
|
crowdsecAppsecHost: "@@APPSEC_HOST@@"
|
||||||
forwardedHeadersTrustedIps:
|
forwardedHeadersTrustedIps:
|
||||||
|
|||||||
@@ -13,23 +13,11 @@ SCENARIO=appsec
|
|||||||
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
|
# plugin's AppSec path end to end (header forwarding + allow/block handling); it
|
||||||
# does not test the real WAF's detection accuracy.
|
# does not test the real WAF's detection accuracy.
|
||||||
body() {
|
body() {
|
||||||
echo "[$SCENARIO] benign request must pass (AppSec 200)"
|
echo "[$SCENARIO] benign request must pass (AppSec allows)"
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
echo "[$SCENARIO] request that return 403 must be blocked (AppSec 403)"
|
echo "[$SCENARIO] request whose URI contains 'rpc2' 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"
|
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 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&______"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run_scenario "$SCENARIO" "$HERE" body
|
run_scenario "$SCENARIO" "$HERE" body
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ body() {
|
|||||||
echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)"
|
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"
|
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)"
|
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"
|
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>E2E ban marker</title></head>
|
||||||
|
<body>
|
||||||
|
<h1 id="e2e-ban-marker">E2E_CUSTOM_BAN_PAGE_MARKER</h1>
|
||||||
|
<p>IP: {{ .ClientIP }} reason: {{ .RemediationReason }}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"marker": "E2E_CUSTOM_BAN_PAGE_MARKER",
|
|
||||||
"body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}"
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,5 @@ http:
|
|||||||
crowdsecLapiKey: "@@APIKEY@@"
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
forwardedHeadersTrustedIps:
|
forwardedHeadersTrustedIps:
|
||||||
- "127.0.0.1/32"
|
- "127.0.0.1/32"
|
||||||
banFilePath: "@@SCENARIO_DIR@@/ban.json"
|
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
|
||||||
remediationHeadersCustomName: "X-E2E-Remediation"
|
remediationHeadersCustomName: "X-E2E-Remediation"
|
||||||
traceHeadersCustomName: x-trace
|
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ body() {
|
|||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
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"
|
echo "[$SCENARIO] banned response Content-Type is HTML"
|
||||||
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4"
|
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] banned response body contains the custom marker"
|
echo "[$SCENARIO] banned response body contains the custom marker"
|
||||||
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
|
assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
echo "[$SCENARIO] banned response 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)"
|
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"
|
assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
defaultDecisionSeconds: "2"
|
|
||||||
# HTTPS LAPI with NO custom CA configured: the bouncer must fall back to
|
|
||||||
# the OS/system trust store (which the scenario controls via SSL_CERT_FILE).
|
|
||||||
crowdsecLapiScheme: https
|
|
||||||
crowdsecLapiHost: "@@LAPI_HOST@@"
|
|
||||||
crowdsecLapiKey: "@@APIKEY@@"
|
|
||||||
forwardedHeadersTrustedIps:
|
|
||||||
- "127.0.0.1/32"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Scenario: HTTPS LAPI with no custom CA configured -> the bouncer must fall back
|
|
||||||
# to the OS/system trust store (PR #331). In the binary suite the "system trust
|
|
||||||
# store" is whatever Go's x509.SystemCertPool() reads, which honours SSL_CERT_FILE
|
|
||||||
# on the Traefik process. We mint a throwaway CA, serve the mock LAPI over HTTPS
|
|
||||||
# with a cert signed by it, and run the stack twice:
|
|
||||||
#
|
|
||||||
# positive: SSL_CERT_FILE = our CA -> LAPI trusted -> 200
|
|
||||||
# negative: SSL_CERT_FILE = empty bundle -> LAPI not trusted -> 403
|
|
||||||
#
|
|
||||||
# live mode is fail-closed, so a TLS error becomes a 403. The negative run proves
|
|
||||||
# the patch still VERIFIES (it is not an insecure skip).
|
|
||||||
#
|
|
||||||
# Extra dependency vs other scenarios: openssl.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
# shellcheck source=../../lib/common.sh
|
|
||||||
source "$HERE/../../lib/common.sh"
|
|
||||||
|
|
||||||
SCENARIO=tls-system-ca
|
|
||||||
SCENARIO_NAME="$SCENARIO"
|
|
||||||
SCENARIO_LOG="/tmp/e2e-mock-${SCENARIO}.log"
|
|
||||||
CERT_DIR="$(mktemp -d)"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
local rc=$?
|
|
||||||
if (( rc != 0 )); then
|
|
||||||
dump_diagnostics > "$SCENARIO_LOG" 2>&1 || true
|
|
||||||
echo "[$SCENARIO] failed. Logs written to $SCENARIO_LOG" >&2
|
|
||||||
fi
|
|
||||||
stop_stack
|
|
||||||
rm -rf "$CERT_DIR"
|
|
||||||
exit $rc
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
echo "[$SCENARIO] minting throwaway CA + LAPI cert (SAN=IP:127.0.0.1)..."
|
|
||||||
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/ca.key" 2>/dev/null
|
|
||||||
openssl req -x509 -new -key "$CERT_DIR/ca.key" -sha256 -days 3650 \
|
|
||||||
-subj "/CN=crowdsec-bouncer e2e test CA" -out "$CERT_DIR/ca.crt" 2>/dev/null
|
|
||||||
openssl ecparam -name prime256v1 -genkey -noout -out "$CERT_DIR/lapi.key" 2>/dev/null
|
|
||||||
openssl req -new -key "$CERT_DIR/lapi.key" -subj "/CN=lapi" -out "$CERT_DIR/lapi.csr" 2>/dev/null
|
|
||||||
openssl x509 -req -in "$CERT_DIR/lapi.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
|
|
||||||
-CAcreateserial -days 3650 -sha256 -out "$CERT_DIR/lapi.crt" \
|
|
||||||
-extfile <(printf "subjectAltName=IP:127.0.0.1\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth") 2>/dev/null
|
|
||||||
: > "$CERT_DIR/empty.crt" # an empty bundle = a system store that trusts nothing
|
|
||||||
|
|
||||||
# The mock serves the same CA-signed cert in both runs; only Traefik's trust differs.
|
|
||||||
export LAPI_TLS_CERT="$CERT_DIR/lapi.crt" LAPI_TLS_KEY="$CERT_DIR/lapi.key"
|
|
||||||
|
|
||||||
echo "[$SCENARIO] === positive: CA in the system trust store ==="
|
|
||||||
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/ca.crt"
|
|
||||||
start_stack "$HERE"
|
|
||||||
echo "[$SCENARIO] HTTPS LAPI verifies via system trust store -> request passes (200)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
stop_stack
|
|
||||||
|
|
||||||
echo "[$SCENARIO] === negative: CA absent from the system trust store ==="
|
|
||||||
export TRAEFIK_SSL_CERT_FILE="$CERT_DIR/empty.crt"
|
|
||||||
start_stack "$HERE"
|
|
||||||
echo "[$SCENARIO] LAPI cert not trusted -> TLS fails, fail-closed (403)"
|
|
||||||
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4"
|
|
||||||
stop_stack
|
|
||||||
|
|
||||||
echo "[$SCENARIO] OK"
|
|
||||||
Reference in New Issue
Block a user