diff --git a/README.md b/README.md index 62c21ca..a63a466 100644 --- a/README.md +++ b/README.md @@ -513,14 +513,14 @@ make run - int64 - default: 1800 (= 30 minutes) - Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid -- CaptchaHTMLFilePath +- CaptchaFilePath - string - default: /captcha.html - - Path where the captcha template is stored -- BanHTMLFilePath + - Path where the captcha template is stored. The Content-Type header is automatically inferred from the file extension. +- BanFilePath - string - default: "" - - Path where the ban html file is stored (default empty ""=disabled) + - Path where the ban file is stored (default empty ""=disabled). The Content-Type header is automatically inferred from the file extension. - TraceHeadersCustomName - string - default: "" diff --git a/bouncer.go b/bouncer.go index 39037bd..65e5db4 100644 --- a/bouncer.go +++ b/bouncer.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - htmltemplate "html/template" "io" "log/slog" "net/http" @@ -110,7 +109,8 @@ type Bouncer struct { crowdsecStreamRoute string crowdsecHeader string redisUnreachableBlock bool - banTemplate *htmltemplate.Template + banTemplate *template.Template + banTemplateContentType string traceCustomHeader string clientPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy @@ -123,10 +123,18 @@ type Bouncer struct { // New creates the crowdsec bouncer plugin. // -//nolint:nestif,gocyclo,gocognit +//nolint:nestif,gocyclo,gocognit,funlen,maintidx func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { config.LogLevel = strings.ToUpper(config.LogLevel) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat) + + if config.BanFilePath == "" && config.BanHTMLFilePath != "" { + config.BanFilePath = config.BanHTMLFilePath + } + if config.CaptchaHTMLFilePath != "" { + config.CaptchaFilePath = config.CaptchaHTMLFilePath + } + err := configuration.ValidateParams(config, log) if err != nil { log.Error("New:validateParams " + err.Error()) @@ -184,9 +192,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam } } - var banTemplate *htmltemplate.Template - if config.BanHTMLFilePath != "" { - banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath) + var banTemplate *template.Template + var banTemplateContentType string + if config.BanFilePath != "" { + banTemplate, banTemplateContentType, _ = configuration.GetTemplate(config.BanFilePath) } bouncer := &Bouncer{ @@ -219,6 +228,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam remediationStatusCode: config.RemediationStatusCode, redisUnreachableBlock: config.RedisCacheUnreachableBlock, banTemplate: banTemplate, + banTemplateContentType: banTemplateContentType, traceCustomHeader: config.TraceHeadersCustomName, crowdsecStreamRoute: crowdsecStreamRoute, crowdsecHeader: crowdsecHeader, @@ -276,7 +286,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaSiteKey, config.CaptchaSecretKey, config.RemediationHeadersCustomName, - config.CaptchaHTMLFilePath, + config.CaptchaFilePath, config.CaptchaGracePeriodSeconds, ) if err != nil { @@ -433,14 +443,9 @@ func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Req if bouncer.remediationCustomHeader != "" { rw.Header().Set(bouncer.remediationCustomHeader, "ban") } - if bouncer.banTemplate == nil { - rw.WriteHeader(bouncer.remediationStatusCode) - return - } - rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.Header().Set("Content-Type", bouncer.banTemplateContentType) rw.WriteHeader(bouncer.remediationStatusCode) - - if req.Method == http.MethodHead { + if bouncer.banTemplate == nil || req.Method == http.MethodHead { return } templateData := map[string]string{ diff --git a/bouncer_logging_test.go b/bouncer_logging_test.go index b2834d0..29dce00 100644 --- a/bouncer_logging_test.go +++ b/bouncer_logging_test.go @@ -32,13 +32,13 @@ func getTestConfig() *configuration.Config { ForwardedHeadersTrustedIPs: []string{"127.0.0.1"}, ForwardedHeadersCustomName: "", RemediationStatusCode: 403, - BanHTMLFilePath: "", + BanFilePath: "", RemediationHeadersCustomName: "", CaptchaProvider: "", CaptchaSiteKey: "", CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1, - CaptchaHTMLFilePath: "", + CaptchaFilePath: "", RedisCacheEnabled: false, RedisCacheHost: "", RedisCachePassword: "", diff --git a/bouncer_test.go b/bouncer_test.go index 3146ed0..f4f369e 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -2,7 +2,6 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck import ( "context" - htmltemplate "html/template" "net/http" "net/http/httptest" "reflect" @@ -190,11 +189,11 @@ func Test_crowdsecQuery(t *testing.T) { func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { html := "You are banned" - banTemplate, _ := htmltemplate.New("html").Parse(html) + banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html) tests := []struct { name string method string - banTemplate *htmltemplate.Template + banTemplate *template.Template expectBodyContent bool }{ { @@ -235,6 +234,7 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { remediationStatusCode: http.StatusForbidden, remediationCustomHeader: "X-Test-Remediation", banTemplate: tt.banTemplate, + banTemplateContentType: "text/html; charset=utf-8", } rw := httptest.NewRecorder() @@ -269,6 +269,50 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { } } +func TestHandleBanServeHTTPContentType(t *testing.T) { + html := "You are banned" + banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html) + tests := []struct { + name string + banTemplate *template.Template + banTemplateContentType string + }{ + { + name: "Default HTML content type", + banTemplate: banTemplate, + banTemplateContentType: "text/html; charset=utf-8", + }, + { + name: "Custom JSON content type", + banTemplate: banTemplate, + banTemplateContentType: "application/json", + }, + { + name: "Content type set even when banTemplate is nil", + banTemplate: nil, + banTemplateContentType: "application/json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bouncer := &Bouncer{ + remediationStatusCode: http.StatusForbidden, + banTemplate: tt.banTemplate, + banTemplateContentType: tt.banTemplateContentType, + } + + rw := httptest.NewRecorder() + req := &http.Request{Method: http.MethodGet} + bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST") + + if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType { + t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got) + } + }) + } +} + func TestCaptchaMethodBasedLogic(t *testing.T) { tests := []struct { name string diff --git a/examples/custom-ban-page/README.md b/examples/custom-ban-page/README.md index 21c159d..4074642 100644 --- a/examples/custom-ban-page/README.md +++ b/examples/custom-ban-page/README.md @@ -9,11 +9,11 @@ This can be usefull as some browser (Firefox for instance) return a 403 blank we ```yaml labels: - # Define ban HTML file path - - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html" + # Define ban file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" ``` -The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build). +The ban file must be present in the Traefik container (bind mounted or added during a custom build). It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik. ```yaml diff --git a/examples/custom-ban-page/docker-compose.yml b/examples/custom-ban-page/docker-compose.yml index 74c61e5..78cec29 100644 --- a/examples/custom-ban-page/docker-compose.yml +++ b/examples/custom-ban-page/docker-compose.yml @@ -42,8 +42,8 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - # Define ban HTML file path - - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html" + # Define ban file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.banFilePath=/ban.html" crowdsec: image: crowdsecurity/crowdsec:v1.6.1-2 diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index c356779..ce20506 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -4,11 +4,11 @@ package captcha import ( "encoding/json" "fmt" - "html/template" "log/slog" "net/http" "net/url" "strings" + "text/template" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" @@ -21,7 +21,8 @@ type Client struct { secretKey string remediationCustomHeader string gracePeriodSeconds int64 - captchaTemplate *template.Template + templateContentType string + template *template.Template cacheClient *cache.Client httpClient *http.Client log *slog.Logger @@ -74,8 +75,9 @@ func (c *Client) New(log *slog.Logger, cacheClient *cache.Client, httpClient *ht c.siteKey = siteKey c.secretKey = secretKey c.remediationCustomHeader = remediationCustomHeader - html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) - c.captchaTemplate = html + template, contentType, _ := configuration.GetTemplate(captchaTemplatePath) + c.template = template + c.templateContentType = contentType c.gracePeriodSeconds = gracePeriodSeconds c.log = log c.httpClient = httpClient @@ -100,12 +102,12 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str http.Redirect(rw, r, r.URL.String(), http.StatusFound) return } - rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.Header().Set("Content-Type", c.templateContentType) if c.remediationCustomHeader != "" { rw.Header().Set(c.remediationCustomHeader, "captcha") } rw.WriteHeader(http.StatusOK) - err = c.captchaTemplate.Execute(rw, map[string]string{ + err = c.template.Execute(rw, map[string]string{ "SiteKey": c.siteKey, "FrontendJS": c.infoProvider.js, "FrontendKey": c.infoProvider.key, diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index b8411e5..c787bdd 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -6,7 +6,6 @@ import ( "crypto/x509" "errors" "fmt" - "html/template" "log/slog" "net/http" "net/url" @@ -15,6 +14,7 @@ import ( "reflect" "regexp" "strings" + "text/template" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ) @@ -99,8 +99,10 @@ type Config struct { RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` RedisCacheUnreachableBlock bool `json:"redisCacheUnreachableBlock,omitempty"` - BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` - CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` + BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility + BanFilePath string `json:"banFilePath,omitempty"` + CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` // Deprecated: Keep it for historical compatibility + CaptchaFilePath string `json:"captchaFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` @@ -159,8 +161,8 @@ func New() *Config { CaptchaSiteKey: "", CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1800, - CaptchaHTMLFilePath: "/captcha.html", - BanHTMLFilePath: "", + CaptchaFilePath: "/captcha.html", + BanFilePath: "", TraceHeadersCustomName: "", RemediationHeadersCustomName: "", ForwardedHeadersCustomName: "X-Forwarded-For", @@ -201,28 +203,50 @@ func GetVariable(config *Config, key string) (string, error) { return strings.TrimSpace(value), nil } -// GetHTMLTemplate get compiled HTML template. -func GetHTMLTemplate(path string) (*template.Template, error) { - var err error +func getContentTypeFromPath(path string) string { if path == "" { - return nil, errors.New("no html template provided") + return "" } + ext := strings.ToLower(filepath.Ext(path)) + contentTypeMap := map[string]string{ + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".json": "application/json", + ".txt": "text/plain", + ".xml": "application/xml", + ".js": "application/javascript", + ".css": "text/css", + } + if contentType, ok := contentTypeMap[ext]; ok { + return contentType + } + // Default to HTML for backward compatibility + return "text/html; charset=utf-8" +} + +// GetTemplate get compiled template with {{ and }} delimiters. +// Uses text/template for all file types to avoid HTML escaping issues. +func GetTemplate(path string) (*template.Template, string, error) { + if path == "" { + return nil, "", errors.New("no template file provided") + } + contentType := getContentTypeFromPath(path) //nolint:gosec b, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, "", err } - html := string(b) - compiledTemplate, err := template.New("html").Parse(html) + content := string(b) + compiledTemplate, err := template.New(filepath.Base(path)).Delims("{{", "}}").Parse(content) if err != nil { - return nil, fmt.Errorf("impossible to compile html template: %w", err) + return nil, "", fmt.Errorf("impossible to compile template %s: %w", path, err) } - return compiledTemplate, nil + return compiledTemplate, contentType, nil } // ValidateParams validate all the param gave by user. // -//nolint:gocyclo,gocognit +//nolint:gocyclo,gocognit,nestif func ValidateParams(config *Config, log *slog.Logger) error { if err := validateParamsRequired(config); err != nil { return err @@ -260,12 +284,14 @@ func ValidateParams(config *Config, log *slog.Logger) error { if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil { return err } - if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil { - return err + if config.CaptchaFilePath != "" { + if _, _, err := GetTemplate(config.CaptchaFilePath); err != nil { + return err + } } } - if config.BanHTMLFilePath != "" { - if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil { + if config.BanFilePath != "" { + if _, _, err := GetTemplate(config.BanFilePath); err != nil { return err } } diff --git a/pkg/configuration/configuration_test.go b/pkg/configuration/configuration_test.go index e1c4e61..c6a9240 100644 --- a/pkg/configuration/configuration_test.go +++ b/pkg/configuration/configuration_test.go @@ -301,3 +301,28 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) { }) } } + +func Test_getContentTypeFromPath(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + {name: "HTML file with .html extension", path: "/ban.html", expected: "text/html; charset=utf-8"}, + {name: "JSON file", path: "/ban.json", expected: "application/json"}, + {name: "Text file", path: "/ban.txt", expected: "text/plain"}, + {name: "File with mixed case extension", path: "/ban.HtMl", expected: "text/html; charset=utf-8"}, + {name: "Unknown extension defaults to HTML", path: "/ban.xyz", expected: "text/html; charset=utf-8"}, + {name: "File without extension", path: "/ban", expected: "text/html; charset=utf-8"}, + {name: "Empty path", path: "", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getContentTypeFromPath(tt.path) + if got != tt.expected { + t.Errorf("GetContentTypeFromPath(%q) = %q, want %q", tt.path, got, tt.expected) + } + }) + } +} diff --git a/tests/e2e/mock/scenarios/captcha/run.sh b/tests/e2e/mock/scenarios/captcha/run.sh index 86ed301..2dd53ad 100755 --- a/tests/e2e/mock/scenarios/captcha/run.sh +++ b/tests/e2e/mock/scenarios/captcha/run.sh @@ -16,6 +16,9 @@ body() { echo "[$SCENARIO] captcha page must be served once the decision is polled (200 + marker)" wait_for_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CAPTCHA_PAGE_MARKER" 15 -H "X-Forwarded-For: 1.2.3.4" + echo "[$SCENARIO] captcha response Content-Type is HTML" + assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4" + echo "[$SCENARIO] captcha response is HTTP 200 (the captcha page itself, not a 403)" assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" diff --git a/tests/e2e/mock/scenarios/custom-ban-page/ban.html b/tests/e2e/mock/scenarios/custom-ban-page/ban.html deleted file mode 100644 index 62c0fe3..0000000 --- a/tests/e2e/mock/scenarios/custom-ban-page/ban.html +++ /dev/null @@ -1,8 +0,0 @@ - - -E2E ban marker - -

E2E_CUSTOM_BAN_PAGE_MARKER

-

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

- - diff --git a/tests/e2e/mock/scenarios/custom-ban-page/ban.json b/tests/e2e/mock/scenarios/custom-ban-page/ban.json new file mode 100644 index 0000000..b4e45ef --- /dev/null +++ b/tests/e2e/mock/scenarios/custom-ban-page/ban.json @@ -0,0 +1,4 @@ +{ + "marker": "E2E_CUSTOM_BAN_PAGE_MARKER", + "body": "IP: {{ .ClientIP }}, reason: {{ .RemediationReason }}, trace: {{ .TraceID }}" +} diff --git a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml index 2bfbdf6..573ad07 100644 --- a/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml +++ b/tests/e2e/mock/scenarios/custom-ban-page/dynamic.yml @@ -24,5 +24,6 @@ http: crowdsecLapiKey: "@@APIKEY@@" forwardedHeadersTrustedIps: - "127.0.0.1/32" - banHtmlFilePath: "@@SCENARIO_DIR@@/ban.html" + banFilePath: "@@SCENARIO_DIR@@/ban.json" remediationHeadersCustomName: "X-E2E-Remediation" + traceHeadersCustomName: x-trace diff --git a/tests/e2e/mock/scenarios/custom-ban-page/run.sh b/tests/e2e/mock/scenarios/custom-ban-page/run.sh index c12b217..e040f37 100755 --- a/tests/e2e/mock/scenarios/custom-ban-page/run.sh +++ b/tests/e2e/mock/scenarios/custom-ban-page/run.sh @@ -15,11 +15,14 @@ body() { wait_for_status "http://127.0.0.1:${WEB_PORT}/foo" 403 15 -H "X-Forwarded-For: 1.2.3.4" echo "[$SCENARIO] banned response Content-Type is HTML" - assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "text/html; charset=utf-8" -H "X-Forwarded-For: 1.2.3.4" + assert_header "http://127.0.0.1:${WEB_PORT}/foo" Content-Type "application/json" -H "X-Forwarded-For: 1.2.3.4" echo "[$SCENARIO] banned response body contains the custom marker" assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "E2E_CUSTOM_BAN_PAGE_MARKER" -H "X-Forwarded-For: 1.2.3.4" + echo "[$SCENARIO] banned response body contains the IP and reason from templating" + assert_body_contains "http://127.0.0.1:${WEB_PORT}/foo" "IP: 1.2.3.4, reason: LAPI, trace: 0123456789" -H "X-Forwarded-For: 1.2.3.4" -H "X-Trace: 0123456789" + echo "[$SCENARIO] banned response carries the custom remediation header (remediationHeadersCustomName)" assert_header "http://127.0.0.1:${WEB_PORT}/foo" X-E2E-Remediation "ban" -H "X-Forwarded-For: 1.2.3.4" }