diff --git a/Makefile b/Makefile index 6a60c8a..cb593af 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,9 @@ run_appsec: run_captcha: docker compose -f examples/captcha/docker-compose.captcha.yml up -d +run_custom_ban_page: + docker compose -f examples/custom-ban-page/docker-compose.yml up -d + run: docker compose -f docker-compose.yml up -d --remove-orphans diff --git a/README.md b/README.md index a23c8bb..58cedc3 100644 --- a/README.md +++ b/README.md @@ -177,15 +177,18 @@ Only one instance of the plugin is *possible*. - CaptchaSecretKey - string - Site secret key for the captcha provider -- CaptchaHTMLFilePath - - string - - default: /captcha.html - - Path where the captcha template is stored - CaptchaGracePeriodSeconds - int64 - default: 1800 (= 30 minutes) - Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid - +- CaptchaHTMLFilePath + - string + - default: /captcha.html + - Path where the captcha template is stored +- BanHTMLFilePath + - string + - default: "" + - Path where the ban html file is stored (default empty ""=disabled) ### Configuration @@ -285,11 +288,12 @@ http: captchaSecretKey: FIXME captchaGracePeriodSeconds: 1800 captchaHTMLFilePath: /captcha.html + banHTMLFilePath: ban.html ``` #### Fill variable with value of file -`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword` and `CrowdsecLapiKey` can be provided with the content as raw or through a file path that Traefik can read. +`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` can be provided with the content as raw or through a file path that Traefik can read. The file variable will be used as preference if both content and file are provided for the same variable. Format is: @@ -378,6 +382,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha #### 9. Using Traefik with Captcha remediation feature enabled [examples/captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/captcha/README.md) +#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md) + ### Local Mode Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub. diff --git a/ban.html b/ban.html new file mode 100644 index 0000000..ffa4a8c --- /dev/null +++ b/ban.html @@ -0,0 +1,329 @@ + + + + + CrowdSec Access Forbidden + + + + + + +
+
+
+ +

CrowdSec Access Forbidden

+
+
+

This security check has been powered by

+ + + + + + + + + + + + + + + + + + + + + CrowdSec + +
+
+
+ + diff --git a/bouncer.go b/bouncer.go index 791c9ad..89c2f82 100644 --- a/bouncer.go +++ b/bouncer.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + htmlTemplate "html/template" "io" "net/http" "net/url" @@ -72,6 +73,7 @@ type Bouncer struct { customHeader string crowdsecStreamRoute string crowdsecHeader string + banTemplate *htmlTemplate.Template clientPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy httpClient *http.Client @@ -118,6 +120,10 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n } config.CrowdsecLapiKey = apiKey } + var banTemplate *htmlTemplate.Template + if config.BanHTMLFilePath != "" { + banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath) + } bouncer := &Bouncer{ next: next, @@ -141,6 +147,7 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n crowdsecStreamRoute: crowdsecStreamRoute, crowdsecHeader: crowdsecHeader, log: log, + banTemplate: banTemplate, serverPoolStrategy: &ip.PoolStrategy{ Checker: serverChecker, }, @@ -219,13 +226,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.customHeader) if err != nil { bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error())) - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) return } isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP) if err != nil { bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error())) - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) return } // if our IP is in the trusted list we bypass the next checks @@ -248,7 +255,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString)) if cacheErrString != cache.CacheMiss { bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString)) - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) return } } else { @@ -256,7 +263,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if value == cache.NoBannedValue { handleNextServeHTTP(bouncer, remoteIP, rw, req) } else { - handleErrorServeHTTP(bouncer, remoteIP, value, rw, req) + handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req) } return } @@ -268,7 +275,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { handleNextServeHTTP(bouncer, remoteIP, rw, req) } else { bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s", remoteIP)) - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) } } else { value, err := handleNoStreamCache(bouncer, remoteIP) @@ -276,7 +283,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { handleNextServeHTTP(bouncer, remoteIP, rw, req) } else { bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error())) - handleErrorServeHTTP(bouncer, remoteIP, value, rw, req) + handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req) } } } @@ -309,8 +316,18 @@ type Login struct { Expire string `json:"expire"` } -func handleErrorServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) { - bouncer.log.Debug(fmt.Sprintf("handleErrorServeHTTP ip:%s remediation:%s", remoteIP, remediation)) +func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) { + rw.WriteHeader(http.StatusForbidden) + if bouncer.banTemplate != nil { + err := bouncer.banTemplate.Execute(rw, map[string]string{"caca": "caca"}) + if err != nil { + bouncer.log.Info(fmt.Sprintf("handleBanServeHTTP banTemplateServe %s", err.Error())) + } + } +} + +func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) { + bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation)) if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue { if bouncer.captchaClient.Check(remoteIP) { handleNextServeHTTP(bouncer, remoteIP, rw, req) @@ -319,14 +336,14 @@ func handleErrorServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw htt bouncer.captchaClient.ServeHTTP(rw, req, remoteIP) return } - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) } func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) { if bouncer.appsecEnabled { if err := appsecQuery(bouncer, remoteIP, req); err != nil { bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error())) - rw.WriteHeader(http.StatusForbidden) + handleBanServeHTTP(bouncer, rw) return } } diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d8e612e..2741212 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -16,6 +16,8 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - logs-local:/var/log/traefik + - './ban.html:/ban.html:ro' + - './captcha.html:/captcha.html:ro' - ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin ports: - 8000:80 @@ -31,9 +33,9 @@ services: - "traefik.enable=true" - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" - "traefik.http.routers.router-foo.entrypoints=web" - - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" + - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" - "traefik.http.services.service-foo.loadbalancer.server.port=80" - + whoami2: image: traefik/whoami container_name: "simple-service-bar" @@ -50,7 +52,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5=" crowdsec: - image: crowdsecurity/crowdsec:1.6.0 + image: crowdsecurity/crowdsec:v1.6.0 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/docker-compose.yml b/docker-compose.yml index cdb0ef1..2b9d88c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,9 +12,11 @@ services: - "--entrypoints.web.address=:80" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - - "--experimental.plugins.bouncer.version=v1.1.15" + - "--experimental.plugins.bouncer.version=v1.3.0-beta1" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" + # - './ban.html:/ban.html:ro' + # - './captcha.html:/captcha.html:ro' - "logs:/var/log/traefik" ports: - 8000:80 @@ -34,7 +36,7 @@ services: - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" # Definition of the service - "traefik.http.services.service-foo.loadbalancer.server.port=80" - + whoami2: image: traefik/whoami container_name: "simple-service-bar" diff --git a/examples/custom-ban-page/README.md b/examples/custom-ban-page/README.md new file mode 100644 index 0000000..18e6b89 --- /dev/null +++ b/examples/custom-ban-page/README.md @@ -0,0 +1,47 @@ +# Example +## Adding a custom ban page + +Traefik can return a custom HTML ban page along with the 403 HTTP response code. +This can be usefull as some browser (Firefox for instance) return a 403 blank webpage and we can mistake a server/reverse-proxy error with a ban from Crowdsec. + +### Traefik configuration + + +```yaml + labels: + # Define ban HTML file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/ban.html" +``` + +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. + +```yaml + ... + traefik: + image: "traefik:v2.11.0" + volumes: + - './ban.html:/ban.html' + ... +``` + +## Exemple navigation + +We can try to query normally the whoami server: +```bash +curl http://localhost:8000/foo +``` + +We can try to ban ourself + +```bash +docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 4h --type ban +``` +![image decision ban](image_decision_ban.png) + +We will see in the browser the ban custom page: + +To play the demo environment run: +```bash +make run_custom_ban_page +``` diff --git a/examples/custom-ban-page/acquis.yaml b/examples/custom-ban-page/acquis.yaml new file mode 100644 index 0000000..5d52554 --- /dev/null +++ b/examples/custom-ban-page/acquis.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/traefik/access.log +labels: + type: traefik diff --git a/examples/custom-ban-page/ban.html b/examples/custom-ban-page/ban.html new file mode 100644 index 0000000..c6b59e0 --- /dev/null +++ b/examples/custom-ban-page/ban.html @@ -0,0 +1,330 @@ + + + + + CrowdSec Access Forbidden + + + + + + +
+
+
+ +

CrowdSec Access Forbidden

+
+
+

This security check has been powered by

+ + + + + + + + + + + + + + + + + + + + + CrowdSec + +
+
+
+ + + \ No newline at end of file diff --git a/examples/custom-ban-page/docker-compose.yml b/examples/custom-ban-page/docker-compose.yml new file mode 100644 index 0000000..8a9fa99 --- /dev/null +++ b/examples/custom-ban-page/docker-compose.yml @@ -0,0 +1,67 @@ +services: + traefik: + image: "traefik:v2.11.0" + container_name: "traefik" + restart: unless-stopped + command: + # - "--log.level=DEBUG" + - "--accesslog" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + + # - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + # - "--experimental.plugins.bouncer.version=v1.2.0" + - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - logs-custom-ban-page-enabled:/var/log/traefik + - './ban.html:/ban.html' + - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - 8000:80 + - 8080:8080 + depends_on: + - crowdsec + + whoami1: + image: traefik/whoami + container_name: "simple-service-foo" + restart: unless-stopped + labels: + - "traefik.enable=true" + # Definition of the router + - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" + - "traefik.http.routers.router-foo.entrypoints=web" + - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" + # Definition of the service + - "traefik.http.services.service-foo.loadbalancer.server.port=80" + # Definition of the middleware + - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" + # Define ban HTML file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html" + + crowdsec: + image: crowdsecurity/crowdsec:v1.6.0 + container_name: "crowdsec" + restart: unless-stopped + environment: + COLLECTIONS: crowdsecurity/traefik + CUSTOM_HOSTNAME: crowdsec + BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5 + volumes: + - './acquis.yaml:/etc/crowdsec/acquis.yaml:ro' + - logs-custom-ban-page-enabled:/var/log/traefik:ro + - crowdsec-db-custom-ban-page-enabled:/var/lib/crowdsec/data/ + - crowdsec-config-custom-ban-page-enabled:/etc/crowdsec/ + labels: + - "traefik.enable=false" + +volumes: + logs-custom-ban-page-enabled: + crowdsec-db-custom-ban-page-enabled: + crowdsec-config-custom-ban-page-enabled: diff --git a/examples/custom-ban-page/image_decision_ban.png b/examples/custom-ban-page/image_decision_ban.png new file mode 100644 index 0000000..62c50e3 Binary files /dev/null and b/examples/custom-ban-page/image_decision_ban.png differ diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 15d73fc..af17e76 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -7,7 +7,6 @@ import ( "html/template" "net/http" "net/url" - "os" "strings" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" @@ -22,7 +21,7 @@ type Client struct { siteKey string secretKey string gracePeriodSeconds int64 - htmlPage *template.Template + captchaTemplate *template.Template cacheClient *cache.Client httpClient *http.Client log *logger.Log @@ -55,26 +54,8 @@ var ( } ) -func compileTemplate(path string) (*template.Template, error) { - var err error - if path == "" { - return nil, fmt.Errorf("no captcha template provided") - } - //nolint:gosec - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - html := string(b) - compiledTemplate, err := template.New("captcha").Parse(html) - if err != nil { - return nil, fmt.Errorf("impossible to compile captcha template: %w", err) - } - return compiledTemplate, nil -} - // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, htmlPagePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil @@ -82,11 +63,8 @@ func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *htt c.siteKey = siteKey c.secretKey = secretKey c.provider = provider - html, err := compileTemplate(htmlPagePath) - if err != nil { - return err - } - c.htmlPage = html + html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) + c.captchaTemplate = html c.gracePeriodSeconds = gracePeriodSeconds c.log = log c.httpClient = httpClient @@ -108,13 +86,13 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str http.Redirect(rw, r, r.URL.String(), http.StatusFound) return } - err = c.htmlPage.Execute(rw, map[string]string{ + err = c.captchaTemplate.Execute(rw, map[string]string{ "SiteKey": c.siteKey, "FrontendJS": captcha[c.provider].js, "FrontendKey": captcha[c.provider].key, }) if err != nil { - c.log.Info("captcha:ServeHTTP Can't serve HTML") + c.log.Info(fmt.Sprintf("captcha:ServeHTTP captchaTemplateServe %s", err.Error())) } } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 1e867f9..7c083b8 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "html/template" "net/http" "net/url" "os" @@ -66,6 +67,7 @@ type Config struct { RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` + BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` @@ -103,8 +105,9 @@ func New() *Config { CaptchaProvider: "", CaptchaSiteKey: "", CaptchaSecretKey: "", - CaptchaHTMLFilePath: "/captcha.html", CaptchaGracePeriodSeconds: 1800, + CaptchaHTMLFilePath: "/captcha.html", + BanHTMLFilePath: "", ForwardedHeadersCustomName: "X-Forwarded-For", ForwardedHeadersTrustedIPs: []string{}, ClientTrustedIPs: []string{}, @@ -142,6 +145,25 @@ func GetVariable(config *Config, key string) (string, error) { return strings.TrimSpace(value), nil } +// GetHTMLTemplate get compiled HTML template. +func GetHTMLTemplate(path string) (*template.Template, error) { + var err error + if path == "" { + return nil, fmt.Errorf("no html template provided") + } + //nolint:gosec + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + html := string(b) + compiledTemplate, err := template.New("html").Parse(html) + if err != nil { + return nil, fmt.Errorf("impossible to compile html template: %w", err) + } + return compiledTemplate, nil +} + // ValidateParams validate all the param gave by user. // //nolint:gocyclo,gocognit @@ -178,6 +200,14 @@ func ValidateParams(config *Config) error { if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil { return err } + if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil { + return err + } + } + if config.BanHTMLFilePath != "" { + if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil { + return err + } } if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {