Skip to content

Commit

Permalink
✨ add support for appsec in crowdsec (#123)
Browse files Browse the repository at this point in the history
* ✨ add support for appsec in crowdsec

* 🐛 lint

* 🐛 fix lint

* 🐛 fix lint

* 🐛 fix lint

* fix: comments

* 🐛 lint and doc

* 🐛 fix comment and lint

* 📝 Start documentation for appsec with exemple

* 📝 Fix readme typos and update example

* 🚨 Fix Lint

---------

Co-authored-by: Mathieu Hanotaux <[email protected]>
  • Loading branch information
maxlerebourg and mathieuHa committed Jan 24, 2024
1 parent fc3da2f commit b68c692
Show file tree
Hide file tree
Showing 10 changed files with 311 additions and 54 deletions.
53 changes: 30 additions & 23 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,66 +20,73 @@ clean:
rm -rf ./vendor

run_dev:
docker-compose -f docker-compose.dev.yml up -d --remove-orphans
docker compose -f docker-compose.dev.yml up -d --remove-orphans

run_local:
docker-compose -f docker-compose.local.yml up -d --remove-orphans
docker compose -f docker-compose.local.yml up -d --remove-orphans

run_behindproxy:
docker-compose -f examples/behind-proxy/docker-compose.cloudflare.yml up -d --remove-orphans
docker compose -f examples/behind-proxy/docker-compose.cloudflare.yml up -d --remove-orphans

run_cacheredis:
docker-compose -f examples/redis-cache/docker-compose.redis.yml up -d --remove-orphans
docker compose -f examples/redis-cache/docker-compose.redis.yml up -d --remove-orphans

run_trustedips:
docker-compose -f examples/trusted-ips/docker-compose.trusted.yml up -d --remove-orphans
docker compose -f examples/trusted-ips/docker-compose.trusted.yml up -d --remove-orphans

run_binaryvm:
cd examples/binary-vm/ && sudo vagrant up

run_tlsauth:
docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml down && docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml up -d && docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml restart && docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml logs -f
docker compose -f examples/tls-auth/docker-compose.tls-auth.yml down && docker compose -f examples/tls-auth/docker-compose.tls-auth.yml up -d && docker compose -f examples/tls-auth/docker-compose.tls-auth.yml restart && docker compose -f examples/tls-auth/docker-compose.tls-auth.yml logs -f

run_appsec:
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml up -d

run:
docker-compose -f docker-compose.yml up -d --remove-orphans
docker compose -f docker-compose.yml up -d --remove-orphans

restart_dev:
docker-compose -f docker-compose.dev.yml restart
docker compose -f docker-compose.dev.yml restart

restart_local:
docker-compose -f docker-compose.local.yml restart
docker compose -f docker-compose.local.yml restart

restart:
docker-compose -f docker-compose.yml restart
docker compose -f docker-compose.yml restart

restart_behindproxy:
docker-compose -f examples/behind-proxy/docker-compose.cloudflare.yml restart
docker compose -f examples/behind-proxy/docker-compose.cloudflare.yml restart

restart_cacheredis:
docker-compose -f examples/redis-cache/docker-compose.redis.yml restart
docker compose -f examples/redis-cache/docker-compose.redis.yml restart

restart_trustedips:
docker-compose -f examples/trusted-ips/docker-compose.trusted.yml restart
docker compose -f examples/trusted-ips/docker-compose.trusted.yml restart

restart_tlsauth:
docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml
docker compose -f examples/tls-auth/docker-compose.tls-auth.yml

restart_appsec:
docker compose -f examples/tls-auth/docker-compose.appsec-enabled.yml

show_logs:
docker-compose -f docker-compose.yml restart
docker compose -f docker-compose.yml restart

show_local_logs:
docker-compose -f docker-compose.local.yml logs -f
docker compose -f docker-compose.local.yml logs -f

show_dev_logs:
docker-compose -f docker-compose.dev.yml logs -f
docker compose -f docker-compose.dev.yml logs -f

clean_all_docker:
docker-compose -f examples/behind-proxy/docker-compose.cloudflare.yml down --remove-orphans
docker-compose -f examples/redis-cache/docker-compose.redis.yml down --remove-orphans
docker-compose -f examples/trusted-ips/docker-compose.trusted.yml down --remove-orphans
docker-compose -f examples/tls-auth/docker-compose.tls-auth.yml down --remove-orphans
docker-compose -f docker-compose.local.yml down --remove-orphans
docker-compose -f docker-compose.yml down --remove-orphans
docker compose -f examples/behind-proxy/docker-compose.cloudflare.yml down --remove-orphans
docker compose -f examples/redis-cache/docker-compose.redis.yml down --remove-orphans
docker compose -f examples/trusted-ips/docker-compose.trusted.yml down --remove-orphans
docker compose -f examples/tls-auth/docker-compose.tls-auth.yml down --remove-orphans
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml down --remove-orphans
docker compose -f docker-compose.local.yml down --remove-orphans
docker compose -f docker-compose.yml down --remove-orphans

clean_vagrant:
cd examples/binary-vm/ && sudo vagrant destroy -f
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

# Crowdsec Bouncer Traefik plugin

> New! This plugin now supports [AppSec](https://doc.crowdsec.net/docs/next/appsec/intro/) feature including virtual patching and capabilities support for your legacy ModSecurity rules.
This plugin aims to implement a Crowdsec Bouncer in a Traefik plugin.

> [CrowdSec](https://www.crowdsec.net/) is an open-source and collaborative IPS (Intrusion Prevention System) and a security suite.
Expand All @@ -17,6 +19,16 @@ The Crowdsec utility will provide the community blocklist which contains highly

When used with Crowdsec it will leverage the local API which will analyze Traefik logs and take decisions on the requests made by users/bots. Malicious actors will be banned based on patterns used against your website.

Appsec feature is supported from plugin version 1.2.0 and Crowdsec 1.6.0.

The AppSec Component offers:

- Low-effort virtual patching capabilities.
- Support for your legacy ModSecurity rules.
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).


There are 4 operating modes (CrowdsecMode) for this plugin:

| Mode | Description |
Expand Down Expand Up @@ -50,13 +62,25 @@ Only one instance of the plugin is *possible*.
- Enabled
- bool
- default: false
- enable the plugin
- Enable the plugin
- LogLevel
- string
- default: `INFO`, expected values are: `INFO`, `DEBUG`
- CrowdsecMode
- string
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`
- CrowdsecAppsecEnabled
- bool
- default: false
- Enable Crowdsec Appsec Server (WAF).
- CrowdsecAppsecHost
- string
- default: "crowdsec:7422"
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
- CrowdsecAppsecFailureBlock
- bool
- default: true
- Block request when Crowdsec Appsec Server have a [status 500](https://docs.crowdsec.net/docs/next/appsec/protocol#response-code).
- CrowdsecLapiScheme
- string
- default: `http`, expected values are: `http`, `https`
Expand Down Expand Up @@ -179,6 +203,9 @@ http:
defaultDecisionSeconds: 60
httpTimeoutSeconds: 10
crowdsecMode: live
crowdsecAppsecEnabled: false
crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecFailureBlock: true
crowdsecLapiKey: privateKey-foo
crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo
crowdsecLapiHost: crowdsec:8080
Expand Down Expand Up @@ -309,6 +336,10 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10

#### 7. Using Traefik in standalone mode without Crowdsec [examples/standalone-mode/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/standalone-mode/README.md)


#### 8. Using Traefik with AppSec feature enabled [examples/appsec-enabled/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/appsec-enabled/README.md)


### Local Mode

Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
Expand Down
9 changes: 9 additions & 0 deletions acquis.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
---
filenames:
- /var/log/traefik/access.log
labels:
type: traefik

---
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/virtual-patching
name: myAppSecComponent
source: appsec
labels:
type: appsec
114 changes: 96 additions & 18 deletions bouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ import (
)

const (
crowdsecLapiHeader = "X-Api-Key"
crowdsecCapiHeader = "Authorization"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecCapiLogin = "v2/watchers/login"
crowdsecCapiStreamRoute = "v2/decisions/stream"
cacheTimeoutKey = "updated"
crowdsecAppsecIPHeader = "X-Crowdsec-Appsec-Ip"
crowdsecAppsecURIHeader = "X-Crowdsec-Appsec-Uri"
crowdsecAppsecHostHeader = "X-Crowdsec-Appsec-Host"
crowdsecAppsecVerbHeader = "X-Crowdsec-Appsec-Verb"
crowdsecAppsecHeader = "X-Crowdsec-Appsec-Api-Key"
crowdsecLapiHeader = "X-Api-Key"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecCapiHost = "api.crowdsec.net"
crowdsecCapiHeader = "Authorization"
crowdsecCapiLoginRoute = "v2/watchers/login"
crowdsecCapiStreamRoute = "v2/decisions/stream"
cacheTimeoutKey = "updated"
)

//nolint:gochecknoglobals
Expand All @@ -50,6 +56,9 @@ type Bouncer struct {
template *template.Template

enabled bool
appsecEnabled bool
appsecHost string
appsecFailureBlock bool
crowdsecScheme string
crowdsecHost string
crowdsecKey string
Expand Down Expand Up @@ -86,9 +95,9 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n
if config.CrowdsecMode == configuration.AloneMode {
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
config.CrowdsecLapiHost = "api.crowdsec.net"
config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiScheme = "https"
config.UpdateIntervalSeconds = 7200
config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader
} else {
Expand All @@ -114,6 +123,9 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n

enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled,
appsecHost: config.CrowdsecAppsecHost,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost,
crowdsecKey: config.CrowdsecLapiKey,
Expand Down Expand Up @@ -212,7 +224,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if isBanned {
rw.WriteHeader(http.StatusForbidden)
} else {
bouncer.next.ServeHTTP(rw, req)
handleNextServeHTTP(bouncer, remoteIP, rw, req)
}
return
}
Expand All @@ -221,7 +233,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Right here if we cannot join the stream we forbid the request to go on.
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
if isCrowdsecStreamHealthy {
bouncer.next.ServeHTTP(rw, req)
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
logger.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s", remoteIP))
rw.WriteHeader(http.StatusForbidden)
Expand All @@ -232,8 +244,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
} else {
logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:false", remoteIP))
bouncer.next.ServeHTTP(rw, req)
handleNextServeHTTP(bouncer, remoteIP, rw, req)
}
}
}
Expand Down Expand Up @@ -266,6 +277,18 @@ type Login struct {
Expire string `json:"expire"`
}

func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
if bouncer.appsecEnabled {
err := appsecQuery(bouncer, remoteIP, req)
if err != nil {
logger.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
return
}
}
bouncer.next.ServeHTTP(rw, req)
}

func handleStreamTicker(bouncer *Bouncer) {
if err := handleStreamCache(bouncer); err != nil {
isCrowdsecStreamHealthy = false
Expand Down Expand Up @@ -342,7 +365,7 @@ func getToken(bouncer *Bouncer) error {
loginURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: crowdsecCapiLogin,
Path: crowdsecCapiLoginRoute,
}
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
if err != nil {
Expand Down Expand Up @@ -423,6 +446,11 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
if err != nil {
return nil, fmt.Errorf("crowdsecQuery url:%s %w", stringURL, err)
}
defer func() {
if err = res.Body.Close(); err != nil {
logger.Error(fmt.Sprintf("crowdsecQuery:closeBody %s", err.Error()))
}
}()
if res.StatusCode == http.StatusUnauthorized && bouncer.crowdsecMode == configuration.AloneMode {
if errToken := getToken(bouncer); errToken != nil {
return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken)
Expand All @@ -432,15 +460,65 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode)
}
body, err := io.ReadAll(res.Body)

if err != nil {
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
}
return body, nil
}

func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.appsecHost,
Path: "/",
}
var req *http.Request
if httpReq.Body != nil && httpReq.ContentLength > 0 {
bodyBytes, err := io.ReadAll(httpReq.Body)
if err != nil {
return fmt.Errorf("appsecQuery:GetBody %w", err)
}
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
}

for key, headers := range httpReq.Header {
for _, value := range headers {
req.Header.Add(key, value)
}
}
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey)
req.Header.Set(crowdsecAppsecIPHeader, ip)
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.Path)

res, err := bouncer.httpClient.Do(req)
if err != nil {
return fmt.Errorf("appsecQuery %w", err)
}
defer func() {
if err = res.Body.Close(); err != nil {
logger.Error(fmt.Sprintf("crowdsecQuery:closeBody %s", err.Error()))
logger.Error(fmt.Sprintf("appsecQuery:closeBody %s", err.Error()))
}
}()
body, err := io.ReadAll(res.Body)
if res.StatusCode == http.StatusInternalServerError {
logger.Debug("crowdsecQuery statusCode:500")
if bouncer.appsecFailureBlock {
return fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode)
}
return nil
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode)
}

if err != nil {
return nil, fmt.Errorf("crowdsecQuery:readBody %w", err)
return fmt.Errorf("appsecQuery:readBody %w", err)
}
return body, nil
return nil
}
Loading

0 comments on commit b68c692

Please sign in to comment.