mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-19 18:52:20 +02:00
✨ Add parameter to configure the ban page Content-Type response header (#325)
* Add parameter to configure Ban Response Content-Type * Add testing for new BanResponseContentType parameter * Ensure there is a fallback to default Content-Type is user provided empty value * Set Content-Type even if banTemplate is nil * Add more edge cases for testing ban response Content-Type * Add CR/LF validation for BanResponseContentType * Add CaptchaResponseContentType to allow separate Content-Type configuration for captcha responses * Add testing for new CaptchaResponseContentType * Update README * Split nil and CR/LF response Content-Type value validation into separate function * Throw error instead of setting the default in case of empty parameter declaration * Update testing accordingly * ✨ remove HTML from var name, add tests and infer content type from filePath * 🍱 fix lint ? * 🍱 fix lint * 🍱 fix lint * 🍱 fix lint + naming * 🍱 fix lint * 🍱 fuck lint --------- Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
This commit is contained in:
@@ -513,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
|
||||||
- CaptchaHTMLFilePath
|
- CaptchaFilePath
|
||||||
- string
|
- string
|
||||||
- default: /captcha.html
|
- default: /captcha.html
|
||||||
- Path where the captcha template is stored
|
- Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension.
|
||||||
- BanHTMLFilePath
|
- BanFilePath
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
- Path where the ban html file is stored (default empty ""=disabled)
|
- Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension.
|
||||||
- TraceHeadersCustomName
|
- TraceHeadersCustomName
|
||||||
- string
|
- string
|
||||||
- default: ""
|
- default: ""
|
||||||
|
|||||||
+19
-14
@@ -9,7 +9,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
htmltemplate "html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -110,7 +109,8 @@ type Bouncer struct {
|
|||||||
crowdsecStreamRoute string
|
crowdsecStreamRoute string
|
||||||
crowdsecHeader string
|
crowdsecHeader string
|
||||||
redisUnreachableBlock bool
|
redisUnreachableBlock bool
|
||||||
banTemplate *htmltemplate.Template
|
banTemplate *template.Template
|
||||||
|
banTemplateContentType string
|
||||||
traceCustomHeader string
|
traceCustomHeader string
|
||||||
clientPoolStrategy *ip.PoolStrategy
|
clientPoolStrategy *ip.PoolStrategy
|
||||||
serverPoolStrategy *ip.PoolStrategy
|
serverPoolStrategy *ip.PoolStrategy
|
||||||
@@ -123,10 +123,18 @@ type Bouncer struct {
|
|||||||
|
|
||||||
// New creates the crowdsec bouncer plugin.
|
// New creates the crowdsec bouncer plugin.
|
||||||
//
|
//
|
||||||
//nolint:nestif,gocyclo,gocognit
|
//nolint:nestif,gocyclo,gocognit,funlen,maintidx
|
||||||
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
|
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())
|
||||||
@@ -184,9 +192,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var banTemplate *htmltemplate.Template
|
var banTemplate *template.Template
|
||||||
if config.BanHTMLFilePath != "" {
|
var banTemplateContentType string
|
||||||
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
if config.BanFilePath != "" {
|
||||||
|
banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
bouncer := &Bouncer{
|
bouncer := &Bouncer{
|
||||||
@@ -219,6 +228,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
|||||||
remediationStatusCode: config.RemediationStatusCode,
|
remediationStatusCode: config.RemediationStatusCode,
|
||||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||||
banTemplate: banTemplate,
|
banTemplate: banTemplate,
|
||||||
|
banTemplateContentType: banTemplateContentType,
|
||||||
traceCustomHeader: config.TraceHeadersCustomName,
|
traceCustomHeader: config.TraceHeadersCustomName,
|
||||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||||
crowdsecHeader: crowdsecHeader,
|
crowdsecHeader: crowdsecHeader,
|
||||||
@@ -276,7 +286,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.CaptchaHTMLFilePath,
|
config.CaptchaFilePath,
|
||||||
config.CaptchaGracePeriodSeconds,
|
config.CaptchaGracePeriodSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -433,14 +443,9 @@ 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")
|
||||||
}
|
}
|
||||||
if bouncer.banTemplate == nil {
|
rw.Header().Set("Content-Type", bouncer.banTemplateContentType)
|
||||||
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{
|
||||||
|
|||||||
@@ -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,
|
||||||
BanHTMLFilePath: "",
|
BanFilePath: "",
|
||||||
RemediationHeadersCustomName: "",
|
RemediationHeadersCustomName: "",
|
||||||
CaptchaProvider: "",
|
CaptchaProvider: "",
|
||||||
CaptchaSiteKey: "",
|
CaptchaSiteKey: "",
|
||||||
CaptchaSecretKey: "",
|
CaptchaSecretKey: "",
|
||||||
CaptchaGracePeriodSeconds: 1,
|
CaptchaGracePeriodSeconds: 1,
|
||||||
CaptchaHTMLFilePath: "",
|
CaptchaFilePath: "",
|
||||||
RedisCacheEnabled: false,
|
RedisCacheEnabled: false,
|
||||||
RedisCacheHost: "",
|
RedisCacheHost: "",
|
||||||
RedisCachePassword: "",
|
RedisCachePassword: "",
|
||||||
|
|||||||
+47
-3
@@ -2,7 +2,6 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
htmltemplate "html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -190,11 +189,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, _ := htmltemplate.New("html").Parse(html)
|
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
method string
|
method string
|
||||||
banTemplate *htmltemplate.Template
|
banTemplate *template.Template
|
||||||
expectBodyContent bool
|
expectBodyContent bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -235,6 +234,7 @@ 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()
|
||||||
@@ -269,6 +269,50 @@ 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
|
||||||
|
|||||||
@@ -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 HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
|
The ban file must be present in the Traefik container (bind mounted or added during a custom build).
|
||||||
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
|
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,8 +42,8 @@ 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 HTML file path
|
# Define ban file path
|
||||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
|
- "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html"
|
||||||
|
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||||
|
|||||||
@@ -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,7 +21,8 @@ type Client struct {
|
|||||||
secretKey string
|
secretKey string
|
||||||
remediationCustomHeader string
|
remediationCustomHeader string
|
||||||
gracePeriodSeconds int64
|
gracePeriodSeconds int64
|
||||||
captchaTemplate *template.Template
|
templateContentType string
|
||||||
|
template *template.Template
|
||||||
cacheClient *cache.Client
|
cacheClient *cache.Client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
@@ -74,8 +75,9 @@ 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
|
||||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
template, contentType, _ := configuration.GetTemplate(captchaTemplatePath)
|
||||||
c.captchaTemplate = html
|
c.template = template
|
||||||
|
c.templateContentType = contentType
|
||||||
c.gracePeriodSeconds = gracePeriodSeconds
|
c.gracePeriodSeconds = gracePeriodSeconds
|
||||||
c.log = log
|
c.log = log
|
||||||
c.httpClient = httpClient
|
c.httpClient = httpClient
|
||||||
@@ -100,12 +102,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
|||||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Set("Content-Type", c.templateContentType)
|
||||||
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.captchaTemplate.Execute(rw, map[string]string{
|
err = c.template.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,7 +6,6 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,6 +14,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -99,8 +99,10 @@ 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"`
|
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
BanFilePath string `json:"banFilePath,omitempty"`
|
||||||
|
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility
|
||||||
|
CaptchaFilePath string `json:"captchaFilePath,omitempty"`
|
||||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
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"`
|
||||||
@@ -159,8 +161,8 @@ func New() *Config {
|
|||||||
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",
|
||||||
@@ -201,28 +203,50 @@ func GetVariable(config *Config, key string) (string, error) {
|
|||||||
return strings.TrimSpace(value), nil
|
return strings.TrimSpace(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHTMLTemplate get compiled HTML template.
|
func getContentTypeFromPath(path string) string {
|
||||||
func GetHTMLTemplate(path string) (*template.Template, error) {
|
|
||||||
var err error
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, errors.New("no html template provided")
|
return ""
|
||||||
}
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
contentTypeMap := map[string]string{
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".htm": "text/html; charset=utf-8",
|
||||||
|
".json": "application/json",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".xml": "application/xml",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
}
|
||||||
|
if contentType, ok := contentTypeMap[ext]; ok {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
// Default to HTML for backward compatibility
|
||||||
|
return "text/html; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplate get compiled template with {{ and }} delimiters.
|
||||||
|
// Uses text/template for all file types to avoid HTML escaping issues.
|
||||||
|
func GetTemplate(path string) (*template.Template, string, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, "", errors.New("no template file provided")
|
||||||
|
}
|
||||||
|
contentType := getContentTypeFromPath(path)
|
||||||
//nolint:gosec
|
//nolint:gosec
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
html := string(b)
|
content := string(b)
|
||||||
compiledTemplate, err := template.New("html").Parse(html)
|
compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("impossible to compile html template: %w", err)
|
return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return compiledTemplate, nil
|
return compiledTemplate, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateParams validate all the param gave by user.
|
// ValidateParams validate all the param gave by user.
|
||||||
//
|
//
|
||||||
//nolint:gocyclo,gocognit
|
//nolint:gocyclo,gocognit,nestif
|
||||||
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
|
||||||
@@ -260,12 +284,14 @@ 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 _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
|
if config.CaptchaFilePath != "" {
|
||||||
return err
|
if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.BanHTMLFilePath != "" {
|
if config.BanFilePath != "" {
|
||||||
if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
|
if _, _, err := GetTemplate(config.BanFilePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,3 +301,28 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_getContentTypeFromPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "JSON file", path: "/ban.json", expected: "application/json"},
|
||||||
|
{name: "Text file", path: "/ban.txt", expected: "text/plain"},
|
||||||
|
{name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"},
|
||||||
|
{name: "Empty path", path: "", expected: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := getContentTypeFromPath(tt.path)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ 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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"marker": "E2E_CUSTOM_BAN_PAGE_MARKER",
|
||||||
|
"body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}"
|
||||||
|
}
|
||||||
@@ -24,5 +24,6 @@ http:
|
|||||||
crowdsecLapiKey: "@@APIKEY@@"
|
crowdsecLapiKey: "@@APIKEY@@"
|
||||||
forwardedHeadersTrustedIps:
|
forwardedHeadersTrustedIps:
|
||||||
- "127.0.0.1/32"
|
- "127.0.0.1/32"
|
||||||
banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html"
|
banFilePath: "@@SCENARIO_DIR@@/ban.json"
|
||||||
remediationHeadersCustomName: "X-E2E-Remediation"
|
remediationHeadersCustomName: "X-E2E-Remediation"
|
||||||
|
traceHeadersCustomName: x-trace
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ body() {
|
|||||||
wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4"
|
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 "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4"
|
assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4"
|
||||||
|
|
||||||
echo "[$SCENARIO] banned response body contains the custom marker"
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user