mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 11:38:59 +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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user