From 37730b6627f903936392fc353d0bf3bba3c08bf8 Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 18 Nov 2025 10:25:04 +0000 Subject: [PATCH 1/6] feat: add experimental HTTP template server Add experimental HTTP template server feature that allows users to render ban and captcha templates via HTTP endpoint instead of using Lua scripts. Key features: - HTTP server with configurable listen address/port and TLS support - Template rendering using Go's native text/template package - Host configuration lookup using Host header from HAProxy - Support for ban and captcha remediation types - Catch-all route for simplified HAProxy configuration - HEAD requests return 403 for ban/captcha remediations Changes: - Add HTTPTemplateServerConfig to bouncer configuration - Implement HTTP template server in pkg/httptemplate - Add Go template renderer in pkg/template - Create .tmpl template files for ban and captcha - Add GetProviderInfo helper in captcha package - Update Dockerfile to create log directory and copy templates - Add HAProxy configuration example for HTTP template server - Update all config files to use canonical header names (X-Crowdsec-*) This is an experimental feature and must be explicitly enabled via http_template_server.enabled in the configuration. --- Dockerfile | 21 +- cmd/root.go | 27 ++ config/crowdsec-spoa-bouncer.yaml | 16 ++ config/haproxy-httptemplate.cfg | 74 +++++ config/haproxy-upstreamproxy.cfg | 8 +- config/haproxy.cfg | 8 +- debian/rules | 4 +- docker-compose.httptemplate-test.yaml | 68 +++++ docker-compose.proxy-test.yaml | 6 +- docker-compose.yaml | 6 +- internal/remediation/captcha/providers.go | 9 + pkg/cfg/config.go | 48 +++- pkg/httptemplate/server.go | 287 +++++++++++++++++++ pkg/template/renderer.go | 44 +++ rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec | 4 + templates/ban.tmpl | 102 +++++++ templates/captcha.tmpl | 141 +++++++++ 17 files changed, 844 insertions(+), 29 deletions(-) create mode 100644 config/haproxy-httptemplate.cfg create mode 100644 docker-compose.httptemplate-test.yaml create mode 100644 pkg/httptemplate/server.go create mode 100644 pkg/template/renderer.go create mode 100644 templates/ban.tmpl create mode 100644 templates/captcha.tmpl diff --git a/Dockerfile b/Dockerfile index ea56133..eba6fc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,16 +26,23 @@ RUN addgroup -S crowdsec-spoa && adduser -S -D -H -s /sbin/nologin -g crowdsec-s ## Create a socket for the spoa to inherit crowdsec-spoa:haproxy user from official haproxy image RUN mkdir -p /run/crowdsec-spoa/ && chown crowdsec-spoa:haproxy /run/crowdsec-spoa/ && chmod 770 /run/crowdsec-spoa/ -## Copy templates -RUN mkdir -p /var/lib/crowdsec/lua/haproxy/templates/ -COPY --from=build /go/src/cs-spoa-bouncer/templates/* /var/lib/crowdsec/lua/haproxy/templates/ +## Create log directory with proper permissions +RUN mkdir -p /var/log/crowdsec-spoa && chown crowdsec-spoa:crowdsec-spoa /var/log/crowdsec-spoa && chmod 755 /var/log/crowdsec-spoa -RUN mkdir -p /usr/local/crowdsec/lua/haproxy/ -COPY --from=build /go/src/cs-spoa-bouncer/lua/* /usr/local/crowdsec/lua/haproxy/ +## Copy Lua files (matching Debian/RPM paths) +RUN mkdir -p /usr/lib/crowdsec-haproxy-spoa-bouncer/lua +COPY --from=build /go/src/cs-spoa-bouncer/lua/* /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ -RUN chown -R root:haproxy /var/lib/crowdsec/lua/haproxy /usr/local/crowdsec/lua/haproxy +## Copy templates (matching Debian/RPM paths) +## Copy .tmpl files explicitly to ensure they're included +RUN mkdir -p /var/lib/crowdsec-haproxy-spoa-bouncer/html +COPY --from=build /go/src/cs-spoa-bouncer/templates/ban.tmpl /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.tmpl +COPY --from=build /go/src/cs-spoa-bouncer/templates/captcha.tmpl /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.tmpl -VOLUME [ "/usr/local/crowdsec/lua/haproxy/", "/var/lib/crowdsec/lua/haproxy/templates/" ] +RUN chown -R root:haproxy /usr/lib/crowdsec-haproxy-spoa-bouncer/lua /var/lib/crowdsec-haproxy-spoa-bouncer/html && \ + chmod -R 755 /usr/lib/crowdsec-haproxy-spoa-bouncer/lua /var/lib/crowdsec-haproxy-spoa-bouncer/html + +VOLUME [ "/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/", "/var/lib/crowdsec-haproxy-spoa-bouncer/html/" ] RUN chmod +x /docker_start.sh diff --git a/cmd/root.go b/cmd/root.go index b14d4c6..bd1a412 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,7 @@ import ( "github.com/crowdsecurity/crowdsec-spoa/pkg/cfg" "github.com/crowdsecurity/crowdsec-spoa/pkg/dataset" "github.com/crowdsecurity/crowdsec-spoa/pkg/host" + "github.com/crowdsecurity/crowdsec-spoa/pkg/httptemplate" "github.com/crowdsecurity/crowdsec-spoa/pkg/metrics" "github.com/crowdsecurity/crowdsec-spoa/pkg/spoa" csbouncer "github.com/crowdsecurity/go-cs-bouncer" @@ -194,6 +195,24 @@ func Execute() error { } } + // Create and start HTTP template server if enabled (after HostManager is created) + var httpTemplateServer *httptemplate.Server + if config.HTTPTemplateServer.Enabled { + httpTemplateLogger := log.WithField("component", "http_template_server") + var err error + httpTemplateServer, err = httptemplate.NewServer(&config.HTTPTemplateServer, HostManager, httpTemplateLogger) + if err != nil { + return fmt.Errorf("failed to create HTTP template server: %w", err) + } + + g.Go(func() error { + if err := httpTemplateServer.Serve(ctx); err != nil { + return fmt.Errorf("HTTP template server failed: %w", err) + } + return nil + }) + } + if config.HostsDir != "" { if err := HostManager.LoadFromDirectory(config.HostsDir); err != nil { return fmt.Errorf("failed to load hosts from directory: %w", err) @@ -252,6 +271,14 @@ func Execute() error { log.Errorf("Failed to shutdown SPOA: %v", shutdownErr) } + // Shutdown HTTP template server if it was started + if httpTemplateServer != nil { + log.Info("Shutting down HTTP template server") + if shutdownErr := httpTemplateServer.Shutdown(shutdownCtx); shutdownErr != nil { + log.Errorf("Failed to shutdown HTTP template server: %v", shutdownErr) + } + } + // Return error only if it was unexpected if err != nil && !isExpectedShutdown { return err diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 770e113..418624c 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -24,3 +24,19 @@ prometheus: enabled: false listen_addr: 127.0.0.1 listen_port: 60601 + +## HTTP Template Server (Experimental) +# This feature allows HAProxy to use an HTTP backend instead of Lua scripts for template rendering +# When enabled, HAProxy can be configured to proxy requests to this server instead of using Lua +# Uses Go native templates (.tmpl files) instead of Lua-style templates +http_template_server: + enabled: false + listen_addr: 127.0.0.1 + listen_port: 8081 + # Optional: Custom template paths (defaults to .tmpl files in standard locations if not specified) + # ban_template_path: /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.tmpl + # captcha_template_path: /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.tmpl + tls: + enabled: false + # cert_file: /path/to/cert.pem + # key_file: /path/to/key.pem diff --git a/config/haproxy-httptemplate.cfg b/config/haproxy-httptemplate.cfg new file mode 100644 index 0000000..fba1435 --- /dev/null +++ b/config/haproxy-httptemplate.cfg @@ -0,0 +1,74 @@ +# https://www.haproxy.com/documentation/hapee/latest/onepage/#home +# HAProxy configuration example using HTTP Template Server instead of Lua +# This demonstrates the experimental HTTP template server feature + +global + log stdout format raw local0 + +defaults + log global + option httplog + timeout client 1m + timeout server 1m + timeout connect 10s + timeout http-keep-alive 2m + timeout queue 15s + timeout tunnel 4h # for websocket + +frontend test + mode http + bind *:9090 + + unique-id-format %[uuid()] + unique-id-header X-Unique-ID + filter spoe engine crowdsec config /etc/haproxy/crowdsec.cfg + + ## Define ACLs for remediation types + acl is_ban var(txn.crowdsec.remediation) -m str "ban" + acl is_captcha var(txn.crowdsec.remediation) -m str "captcha" + acl is_allow var(txn.crowdsec.remediation) -m str "allow" + + ## Set headers for HTTP template server + ## IMPORTANT: Remove any user-provided headers first for security, then set from transaction variables + ## The server will look up host configuration using the Host header (automatically forwarded by HAProxy) + ## Only the remediation type needs to be sent - all other data comes from host configuration + http-request del-header X-Crowdsec-Remediation + http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } + + ## Set a custom header on the request for upstream services to use + http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } + + ## Handle 302 redirect for successful captcha validation (native HAProxy redirect) + http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if is_allow { var(txn.crowdsec.redirect) -m found } + + ## Route to HTTP template server for ban and captcha remediations + ## Instead of using Lua, we proxy to the HTTP template server + ## The server uses a catch-all route, so no path rewriting is needed + ## Use OR operator (||) to combine ACLs for cleaner configuration + use_backend http_template_server if is_ban || is_captcha + + ## Handle captcha cookie management via HAProxy + ## Set captcha cookie when SPOA provides captcha_status (pending or valid) + http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } + ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) + http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + + ## Default backend for allowed requests + use_backend test_backend + +backend test_backend + mode http + server s1 whoami:2020 + +backend crowdsec-spoa + mode tcp + balance roundrobin + server s2 spoa:9000 + server s3 spoa:9001 + +backend http_template_server + mode http + # Proxy to the HTTP template server + # The server will read X-Crowdsec-Remediation header to determine which template to render + server template_server spoa:8081 + diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index 32d026b..7aaf4df 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -6,8 +6,8 @@ global log stdout format raw local0 lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua lua-load /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/crowdsec.lua - setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec/lua/haproxy/templates/ban.html - setenv CROWDSEC_CAPTCHA_TEMPLATE_PATH /var/lib/crowdsec/lua/haproxy/templates/captcha.html + setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html + setenv CROWDSEC_CAPTCHA_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.html defaults log global @@ -41,9 +41,9 @@ frontend test # tcp-request content reject if { var(txn.crowdsec.remediation) -m str "ban" } ## Set a custom header on the request for upstream services to use - http-request set-header X-CrowdSec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } + http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } ## Set a custom header on the request for upstream services to use - http-request set-header X-CrowdSec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } + http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } ## Handle 302 redirect for successful captcha validation (native HAProxy redirect) http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } diff --git a/config/haproxy.cfg b/config/haproxy.cfg index 4e7bc74..79b5321 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -3,8 +3,8 @@ global log stdout format raw local0 lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua lua-load /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/crowdsec.lua - setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec/lua/haproxy/templates/ban.html - setenv CROWDSEC_CAPTCHA_TEMPLATE_PATH /var/lib/crowdsec/lua/haproxy/templates/captcha.html + setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html + setenv CROWDSEC_CAPTCHA_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.html defaults log global @@ -31,9 +31,9 @@ frontend test # tcp-request content reject if { var(txn.crowdsec.remediation) -m str "ban" } ## Set a custom header on the request for upstream services to use - http-request set-header X-CrowdSec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } + http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found } ## Set a custom header on the request for upstream services to use - http-request set-header X-CrowdSec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } + http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found } ## Handle 302 redirect for successful captcha validation (native HAProxy redirect) http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } diff --git a/debian/rules b/debian/rules index 5172f2d..525e0d3 100644 --- a/debian/rules +++ b/debian/rules @@ -30,7 +30,9 @@ override_dh_auto_install: install -m 644 -D "lua/template.lua" "debian/$$PKG/usr/lib/$$PKG/lua/template.lua"; \ mkdir -p "debian/$$PKG/var/lib/$$PKG/html"; \ install -m 644 -D "templates/ban.html" "debian/$$PKG/var/lib/$$PKG/html/ban.html"; \ - install -m 644 -D "templates/captcha.html" "debian/$$PKG/var/lib/$$PKG/html/captcha.html" + install -m 644 -D "templates/captcha.html" "debian/$$PKG/var/lib/$$PKG/html/captcha.html"; \ + install -m 644 -D "templates/ban.tmpl" "debian/$$PKG/var/lib/$$PKG/html/ban.tmpl"; \ + install -m 644 -D "templates/captcha.tmpl" "debian/$$PKG/var/lib/$$PKG/html/captcha.tmpl" execute_after_dh_fixperms: @BOUNCER=crowdsec-spoa-bouncer; \ diff --git a/docker-compose.httptemplate-test.yaml b/docker-compose.httptemplate-test.yaml new file mode 100644 index 0000000..fd0d360 --- /dev/null +++ b/docker-compose.httptemplate-test.yaml @@ -0,0 +1,68 @@ +services: + spoa: + image: crowdsecurity/crowdsec-spoa:latest + build: + context: . + dockerfile: Dockerfile + depends_on: + - crowdsec + volumes: + - sockets:/run/ + - geodb:/var/lib/crowdsec/data/ + - ./config/crowdsec-spoa-bouncer.yaml.local:/etc/crowdsec/bouncers/crowdsec-spoa-bouncer.yaml.local + networks: + crowdsec: + ipv4_address: 10.5.5.254 + deploy: + resources: + limits: + cpus: "4.0" + memory: 250M + + whoami: + image: traefik/whoami:latest + networks: + - crowdsec + command: + - --port=2020 + + haproxy: + image: haproxy:2.9.7-alpine + volumes: + - ./config/haproxy-httptemplate.cfg:/usr/local/etc/haproxy/haproxy.cfg + - ./config/crowdsec.cfg:/etc/haproxy/crowdsec.cfg + - sockets:/run/ + ports: + - "9090:9090" + depends_on: + - crowdsec + - spoa + - whoami + networks: + - crowdsec + + crowdsec: + image: crowdsecurity/crowdsec:latest + environment: + - BOUNCER_KEY_SPOA=+4iYgItcalc9+0tWrvrj9R6Wded/W1IRwRtNmcWR9Ws + - DISABLE_ONLINE_API=true + - CROWDSEC_BYPASS_DB_VOLUME_CHECK=true + volumes: + - geodb:/staging/var/lib/crowdsec/data/ + networks: + - crowdsec + +volumes: + sockets: + driver: local + geodb: + driver: local + +networks: + crowdsec: + driver: bridge + ipam: + driver: default + config: + - subnet: "10.5.5.0/24" + diff --git a/docker-compose.proxy-test.yaml b/docker-compose.proxy-test.yaml index 65f61c3..b2963e4 100644 --- a/docker-compose.proxy-test.yaml +++ b/docker-compose.proxy-test.yaml @@ -8,8 +8,8 @@ services: - crowdsec volumes: - sockets:/run/ - - templates:/var/lib/crowdsec/lua/haproxy/templates/ - - lua:/usr/local/crowdsec/lua/haproxy/ + - templates:/var/lib/crowdsec-haproxy-spoa-bouncer/html/ + - lua:/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ - geodb:/var/lib/crowdsec/data/ - ./config/crowdsec-spoa-bouncer.yaml.local:/etc/crowdsec/bouncers/crowdsec-spoa-bouncer.yaml.local networks: @@ -35,7 +35,7 @@ services: - ./config/haproxy-upstreamproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg - ./config/crowdsec-upstreamproxy.cfg:/etc/haproxy/crowdsec.cfg - sockets:/run/ - - templates:/var/lib/crowdsec/lua/haproxy/templates/ + - templates:/var/lib/crowdsec-haproxy-spoa-bouncer/html/ - lua:/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ # HAProxy is now only accessible via nginx (not exposed directly) depends_on: diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b89453..29af5b5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,8 +9,8 @@ services: - crowdsec volumes: - sockets:/run/ - - templates:/var/lib/crowdsec/lua/haproxy/templates/ - - lua:/usr/local/crowdsec/lua/haproxy/ + - templates:/var/lib/crowdsec-haproxy-spoa-bouncer/html/ + - lua:/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ - geodb:/var/lib/crowdsec/data/ - ./config/crowdsec-spoa-bouncer.yaml.local:/etc/crowdsec/bouncers/crowdsec-spoa-bouncer.yaml.local networks: @@ -37,7 +37,7 @@ services: - ./config/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg - ./config/crowdsec.cfg:/etc/haproxy/crowdsec.cfg - sockets:/run/ - - templates:/var/lib/crowdsec/lua/haproxy/templates/ + - templates:/var/lib/crowdsec-haproxy-spoa-bouncer/html/ - lua:/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ ports: - "9090:9090" diff --git a/internal/remediation/captcha/providers.go b/internal/remediation/captcha/providers.go index 7f92040..85b9384 100644 --- a/internal/remediation/captcha/providers.go +++ b/internal/remediation/captcha/providers.go @@ -35,3 +35,12 @@ func ValidProvider(provider string) bool { _, ok := providers[provider] return ok } + +// GetProviderInfo returns the frontend key and JS URL for a given provider +// Returns empty strings if the provider is not found +func GetProviderInfo(provider string) (frontendKey, frontendJS string) { + if info, ok := providers[provider]; ok { + return info.key, info.js + } + return "", "" +} diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index a98b041..8faacec 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -18,14 +18,30 @@ type PrometheusConfig struct { ListenPort string `yaml:"listen_port"` } +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +type HTTPTemplateServerConfig struct { + Enabled bool `yaml:"enabled"` + ListenAddress string `yaml:"listen_addr"` + ListenPort string `yaml:"listen_port"` + TLS TLSConfig `yaml:"tls"` + BanTemplate string `yaml:"ban_template_path"` + CaptchaTemplate string `yaml:"captcha_template_path"` +} + type BouncerConfig struct { - Logging cslogging.LoggingConfig `yaml:",inline"` - Hosts []*host.Host `yaml:"hosts"` - HostsDir string `yaml:"hosts_dir"` - Geo geo.GeoDatabase `yaml:",inline"` - ListenTCP string `yaml:"listen_tcp"` - ListenUnix string `yaml:"listen_unix"` - PrometheusConfig PrometheusConfig `yaml:"prometheus"` + Logging cslogging.LoggingConfig `yaml:",inline"` + Hosts []*host.Host `yaml:"hosts"` + HostsDir string `yaml:"hosts_dir"` + Geo geo.GeoDatabase `yaml:",inline"` + ListenTCP string `yaml:"listen_tcp"` + ListenUnix string `yaml:"listen_unix"` + PrometheusConfig PrometheusConfig `yaml:"prometheus"` + HTTPTemplateServer HTTPTemplateServerConfig `yaml:"http_template_server"` } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). @@ -73,5 +89,23 @@ func (c *BouncerConfig) Validate() error { return fmt.Errorf("configuration requires at least one listener: set listen_tcp or listen_unix") } + // Validate HTTP template server configuration if enabled + if c.HTTPTemplateServer.Enabled { + if c.HTTPTemplateServer.ListenAddress == "" { + return fmt.Errorf("http_template_server.listen_addr is required when http_template_server.enabled is true") + } + if c.HTTPTemplateServer.ListenPort == "" { + return fmt.Errorf("http_template_server.listen_port is required when http_template_server.enabled is true") + } + if c.HTTPTemplateServer.TLS.Enabled { + if c.HTTPTemplateServer.TLS.CertFile == "" { + return fmt.Errorf("http_template_server.tls.cert_file is required when http_template_server.tls.enabled is true") + } + if c.HTTPTemplateServer.TLS.KeyFile == "" { + return fmt.Errorf("http_template_server.tls.key_file is required when http_template_server.tls.enabled is true") + } + } + } + return nil } diff --git a/pkg/httptemplate/server.go b/pkg/httptemplate/server.go new file mode 100644 index 0000000..7882351 --- /dev/null +++ b/pkg/httptemplate/server.go @@ -0,0 +1,287 @@ +package httptemplate + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation/captcha" + "github.com/crowdsecurity/crowdsec-spoa/pkg/cfg" + "github.com/crowdsecurity/crowdsec-spoa/pkg/host" + "github.com/crowdsecurity/crowdsec-spoa/pkg/template" + log "github.com/sirupsen/logrus" +) + +// Server represents the HTTP template server +type Server struct { + config *cfg.HTTPTemplateServerConfig + logger *log.Entry + server *http.Server + banRenderer *template.Renderer + captchaRenderer *template.Renderer + hostManager *host.Manager +} + +// NewServer creates a new HTTP template server +func NewServer(config *cfg.HTTPTemplateServerConfig, hostManager *host.Manager, logger *log.Entry) (*Server, error) { + if config == nil { + return nil, fmt.Errorf("http template server config is nil") + } + + if !config.Enabled { + return nil, fmt.Errorf("http template server is not enabled") + } + + s := &Server{ + config: config, + logger: logger.WithField("component", "http_template_server"), + hostManager: hostManager, + } + + // Load ban template + banTemplatePath := config.BanTemplate + if banTemplatePath == "" { + banTemplatePath = "/var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.tmpl" + } + var err error + s.banRenderer, err = s.loadTemplate("ban", banTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to load ban template: %w", err) + } + + // Load captcha template + captchaTemplatePath := config.CaptchaTemplate + if captchaTemplatePath == "" { + captchaTemplatePath = "/var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.tmpl" + } + s.captchaRenderer, err = s.loadTemplate("captcha", captchaTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to load captcha template: %w", err) + } + + // Setup HTTP routes + mux := http.NewServeMux() + // Catch-all route that handles both ban and captcha based on header + // This allows HAProxy to route to any path without needing to set-path + mux.HandleFunc("/", s.handleRender) + + // Create HTTP server + listenAddr := net.JoinHostPort(config.ListenAddress, config.ListenPort) + s.server = &http.Server{ + Addr: listenAddr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s, nil +} + +// loadTemplate loads a template from a file path and returns a renderer +func (s *Server) loadTemplate(name, path string) (*template.Renderer, error) { + // Use the provided path directly - it should be absolute or configured by the user + templatePath := path + if !filepath.IsAbs(path) { + // If relative path provided, make it relative to the standard template directory + const templateDir = "/var/lib/crowdsec-haproxy-spoa-bouncer/html" + templatePath = filepath.Join(templateDir, path) + } + + content, err := os.ReadFile(templatePath) + if err != nil { + return nil, fmt.Errorf("failed to read template file %s: %w", templatePath, err) + } + + renderer, err := template.NewRenderer(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to create renderer: %w", err) + } + + s.logger.WithField("template", name).WithField("path", templatePath).Info("loaded template") + return renderer, nil +} + +// handleRender is the main endpoint that handles both ban and captcha rendering +// It reads the remediation type from X-CrowdSec-Remediation header (set by HAProxy) +// It looks up host configuration using the Host header to get ban/captcha settings +// Security: HAProxy must delete any user-provided headers and set them from transaction variables +func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { + // Get remediation from header (set by HAProxy from transaction variables) + // HAProxy configuration should use del-header before set-header to ensure user values are ignored + remediation := r.Header.Get("X-Crowdsec-Remediation") + if remediation == "" { + s.logger.WithFields(log.Fields{ + "remote_addr": r.RemoteAddr, + "headers": r.Header, + }).Warn("X-CrowdSec-Remediation header not found, returning 400") + http.Error(w, "Bad Request: Missing X-CrowdSec-Remediation header", http.StatusBadRequest) + return + } + + // Get hostname from Host header (HAProxy forwards the original Host header) + hostname := r.Host + if hostname == "" { + s.logger.Warn("Host header not found, cannot match host configuration") + http.Error(w, "Bad Request: Missing Host header", http.StatusBadRequest) + return + } + + // Look up host configuration + matchedHost := s.hostManager.MatchFirstHost(hostname) + if matchedHost == nil { + s.logger.WithField("hostname", hostname).Warn("no matching host configuration found") + // If captcha and no host, we can't proceed (same logic as SPOA) + if remediation == "captcha" { + s.logger.Warn("captcha remediation but no host found, cannot render captcha") + http.Error(w, "Internal Server Error: No host configuration for captcha", http.StatusInternalServerError) + return + } + // For ban, we can still render with empty data + matchedHost = nil + } + + // Build template data from host configuration + data := s.buildTemplateData(remediation, matchedHost) + + // Log for security monitoring + s.logger.WithFields(log.Fields{ + "remediation": remediation, + "hostname": hostname, + "remote_addr": r.RemoteAddr, + "host_found": matchedHost != nil, + }).Debug("handling render request") + + var renderer *template.Renderer + var statusCode int + + switch remediation { + case "ban": + renderer = s.banRenderer + statusCode = http.StatusForbidden + case "captcha": + if matchedHost == nil { + s.logger.Error("captcha remediation but no host configuration found") + http.Error(w, "Internal Server Error: No host configuration for captcha", http.StatusInternalServerError) + return + } + renderer = s.captchaRenderer + statusCode = http.StatusOK + case "allow": + // Allow should be handled by HAProxy redirect, but if it reaches here, return 200 + s.logger.Warn("handleRender called for 'allow' remediation - this should be handled by HAProxy redirect") + http.Error(w, "Bad Request: Allow remediation should use HAProxy redirect", http.StatusBadRequest) + return + default: + s.logger.WithField("remediation", remediation).Warn("unknown remediation type") + http.Error(w, fmt.Sprintf("Bad Request: Unknown remediation type: %s", remediation), http.StatusBadRequest) + return + } + + // Set headers + w.Header().Set("Cache-Control", "no-cache, no-store") + + // For HEAD requests, always return 403 for ban and captcha remediations + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusForbidden) + return + } + + // Check if client accepts HTML + if !s.acceptsHTML(r) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + if _, err := w.Write([]byte("Forbidden")); err != nil { + s.logger.WithError(err).Error("failed to write response") + } + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(statusCode) + + // Render template directly to response writer + if err := renderer.Render(w, data); err != nil { + s.logger.WithError(err).Error("failed to render template") + // Headers already sent, can't send error response + return + } +} + +// buildTemplateData builds template data from host configuration +// If matchedHost is nil, returns empty data (for ban without host config) +func (s *Server) buildTemplateData(remediation string, matchedHost *host.Host) template.TemplateData { + data := template.TemplateData{} + + if matchedHost == nil { + // No host configuration - return empty data + // This is acceptable for ban remediation but not for captcha + return data + } + + // Extract ban configuration + data.ContactUsURL = matchedHost.Ban.ContactUsURL + + // Extract captcha configuration + if remediation == "captcha" && matchedHost.Captcha.Provider != "" { + data.CaptchaSiteKey = matchedHost.Captcha.SiteKey + data.CaptchaFrontendKey, data.CaptchaFrontendJS = captcha.GetProviderInfo(matchedHost.Captcha.Provider) + } + + return data +} + +// acceptsHTML checks if the request accepts HTML content +func (s *Server) acceptsHTML(r *http.Request) bool { + accept := r.Header.Get("Accept") + if accept == "" { + return true // Default to HTML if no Accept header + } + return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*") +} + +// Serve starts the HTTP server +func (s *Server) Serve(ctx context.Context) error { + listenAddr := s.server.Addr + protocol := "HTTP" + if s.config.TLS.Enabled { + protocol = "HTTPS" + } + + s.logger.WithFields(log.Fields{ + "address": listenAddr, + "protocol": protocol, + }).Info("starting HTTP template server") + + serverError := make(chan error, 1) + + go func() { + var err error + if s.config.TLS.Enabled { + err = s.server.ListenAndServeTLS(s.config.TLS.CertFile, s.config.TLS.KeyFile) + } else { + err = s.server.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + serverError <- err + } + }() + + select { + case err := <-serverError: + return fmt.Errorf("HTTP template server error: %w", err) + case <-ctx.Done(): + return nil + } +} + +// Shutdown gracefully shuts down the HTTP server +func (s *Server) Shutdown(ctx context.Context) error { + s.logger.Info("shutting down HTTP template server") + return s.server.Shutdown(ctx) +} diff --git a/pkg/template/renderer.go b/pkg/template/renderer.go new file mode 100644 index 0000000..28c179f --- /dev/null +++ b/pkg/template/renderer.go @@ -0,0 +1,44 @@ +package template + +import ( + "fmt" + "io" + "text/template" +) + +// TemplateData holds the data for template rendering +type TemplateData struct { + // Ban template fields + ContactUsURL string + + // Captcha template fields + CaptchaSiteKey string + CaptchaFrontendKey string + CaptchaFrontendJS string +} + +// Renderer handles template rendering using Go's native text/template package +type Renderer struct { + tmpl *template.Template +} + +// NewRenderer creates a new template renderer with the given template content +func NewRenderer(templateContent string) (*Renderer, error) { + tmpl, err := template.New("template").Parse(templateContent) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + return &Renderer{ + tmpl: tmpl, + }, nil +} + +// Render renders the template with the given data directly to the provided io.Writer +func (r *Renderer) Render(w io.Writer, data TemplateData) error { + if err := r.tmpl.Execute(w, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} diff --git a/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec b/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec index cf03b1e..1b4e130 100644 --- a/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec +++ b/rpm/SPECS/crowdsec-haproxy-spoa-bouncer.spec @@ -48,6 +48,8 @@ install -m 644 -D lua/utils.lua %{buildroot}/usr/lib/%{name}/lua/utils.lua install -m 644 -D lua/template.lua %{buildroot}/usr/lib/%{name}/lua/template.lua install -m 644 -D templates/ban.html %{buildroot}%{_localstatedir}/lib/%{name}/html/ban.html install -m 644 -D templates/captcha.html %{buildroot}%{_localstatedir}/lib/%{name}/html/captcha.html +install -m 644 -D templates/ban.tmpl %{buildroot}%{_localstatedir}/lib/%{name}/html/ban.tmpl +install -m 644 -D templates/captcha.tmpl %{buildroot}%{_localstatedir}/lib/%{name}/html/captcha.tmpl %clean rm -rf %{buildroot} @@ -65,6 +67,8 @@ rm -rf %{buildroot} /usr/lib/%{name}/lua/template.lua %{_localstatedir}/lib/%{name}/html/ban.html %{_localstatedir}/lib/%{name}/html/captcha.html +%{_localstatedir}/lib/%{name}/html/ban.tmpl +%{_localstatedir}/lib/%{name}/html/captcha.tmpl %post # Reload systemd units diff --git a/templates/ban.tmpl b/templates/ban.tmpl new file mode 100644 index 0000000..f357f15 --- /dev/null +++ b/templates/ban.tmpl @@ -0,0 +1,102 @@ + + + + CrowdSec Ban + + + + + + +
+
+
+
+ +

CrowdSec Access Forbidden

+

You are unable to visit the website.

+ {{if ne .ContactUsURL ""}} + + Contact Us + + {{end}} +
+
+

+ This security check has been powered by +

+ + + + + + + + + + + + + + + + + + + + + + CrowdSec + + +
+
+
+ + + + diff --git a/templates/captcha.tmpl b/templates/captcha.tmpl new file mode 100644 index 0000000..1494a01 --- /dev/null +++ b/templates/captcha.tmpl @@ -0,0 +1,141 @@ + + + + CrowdSec Captcha + + + + + + +
+
+
+
+ +

CrowdSec Captcha

+
+
+
+
+
+

+ This security check has been powered by +

+ + + + + + + + + + + + + + + + + + + + + + CrowdSec + + +
+
+
+ + + + From 642daae41357db4f8e2e7dd83b45322204963e34 Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 18 Nov 2025 10:31:34 +0000 Subject: [PATCH 2/6] security: remove sensitive header data from logs Remove full header map from log entry to prevent exposure of sensitive data such as cookies, authorization tokens, etc. Log only the essential message that remediation header was not found. --- pkg/httptemplate/server.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/httptemplate/server.go b/pkg/httptemplate/server.go index 7882351..437e169 100644 --- a/pkg/httptemplate/server.go +++ b/pkg/httptemplate/server.go @@ -116,11 +116,8 @@ func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { // HAProxy configuration should use del-header before set-header to ensure user values are ignored remediation := r.Header.Get("X-Crowdsec-Remediation") if remediation == "" { - s.logger.WithFields(log.Fields{ - "remote_addr": r.RemoteAddr, - "headers": r.Header, - }).Warn("X-CrowdSec-Remediation header not found, returning 400") - http.Error(w, "Bad Request: Missing X-CrowdSec-Remediation header", http.StatusBadRequest) + s.logger.Warn("X-Crowdsec-Remediation header not found, returning 400") + http.Error(w, "Bad Request: Missing X-Crowdsec-Remediation header", http.StatusBadRequest) return } From 4c0742ccce37913b787da57e6dbb705c5863fe4a Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 18 Nov 2025 13:57:26 +0000 Subject: [PATCH 3/6] feat: return 403 for HEAD requests and .ico files Return 403 Forbidden for HEAD requests and favicon (.ico) file requests instead of rendering templates. This prevents unnecessary template rendering for these request types and provides a cleaner response. --- pkg/httptemplate/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/httptemplate/server.go b/pkg/httptemplate/server.go index 437e169..5edc300 100644 --- a/pkg/httptemplate/server.go +++ b/pkg/httptemplate/server.go @@ -183,8 +183,8 @@ func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { // Set headers w.Header().Set("Cache-Control", "no-cache, no-store") - // For HEAD requests, always return 403 for ban and captcha remediations - if r.Method == http.MethodHead { + // For HEAD requests or .ico file requests, always return 403 for ban and captcha remediations + if r.Method == http.MethodHead || strings.HasSuffix(strings.ToLower(r.URL.Path), ".ico") { w.WriteHeader(http.StatusForbidden) return } From 1edcca16c7d4f5b30e844fa9369c55b164b5b076 Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 25 Nov 2025 11:03:05 +0000 Subject: [PATCH 4/6] fix: address Copilot PR review comments - Fix duplicate class attributes in ban.tmpl and captcha.tmpl - Update meta charset to HTML5 syntax in both templates - Fix indentation inconsistencies in templates and haproxy config - Move HTTP template server startup after hosts are loaded - Remove redundant error check in httptemplate server - Fix comment typo: X-CrowdSec -> X-Crowdsec - Add template name parameter to NewRenderer for better error messages - Remove unnecessary template volume mount from docker-compose --- cmd/root.go | 17 ++++++++++------- config/haproxy-httptemplate.cfg | 10 +++++----- pkg/httptemplate/server.go | 10 +++------- pkg/template/renderer.go | 9 ++++++--- templates/ban.tmpl | 24 ++++++++++++------------ templates/captcha.tmpl | 14 +++++++------- 6 files changed, 43 insertions(+), 41 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 746649e..5034db8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -179,7 +179,7 @@ func Execute() error { } } - // Create and start HTTP template server if enabled (after HostManager is created) + // Create HTTP template server if enabled (after HostManager is created, but before starting) var httpTemplateServer *httptemplate.Server if config.HTTPTemplateServer.Enabled { httpTemplateLogger := log.WithField("component", "http_template_server") @@ -188,7 +188,16 @@ func Execute() error { if err != nil { return fmt.Errorf("failed to create HTTP template server: %w", err) } + } + + if config.HostsDir != "" { + if err := HostManager.LoadFromDirectory(config.HostsDir); err != nil { + return fmt.Errorf("failed to load hosts from directory: %w", err) + } + } + // Start HTTP template server after hosts are loaded to ensure host configurations are available + if httpTemplateServer != nil { g.Go(func() error { if err := httpTemplateServer.Serve(ctx); err != nil { return fmt.Errorf("HTTP template server failed: %w", err) @@ -197,12 +206,6 @@ func Execute() error { }) } - if config.HostsDir != "" { - if err := HostManager.LoadFromDirectory(config.HostsDir); err != nil { - return fmt.Errorf("failed to load hosts from directory: %w", err) - } - } - // Create single SPOA listener - ultra-simplified architecture spoaLogger := log.WithField("component", "spoa") diff --git a/config/haproxy-httptemplate.cfg b/config/haproxy-httptemplate.cfg index fba1435..48019f1 100644 --- a/config/haproxy-httptemplate.cfg +++ b/config/haproxy-httptemplate.cfg @@ -9,11 +9,11 @@ defaults log global option httplog timeout client 1m - timeout server 1m - timeout connect 10s - timeout http-keep-alive 2m - timeout queue 15s - timeout tunnel 4h # for websocket + timeout server 1m + timeout connect 10s + timeout http-keep-alive 2m + timeout queue 15s + timeout tunnel 4h # for websocket frontend test mode http diff --git a/pkg/httptemplate/server.go b/pkg/httptemplate/server.go index 5edc300..e30dc3a 100644 --- a/pkg/httptemplate/server.go +++ b/pkg/httptemplate/server.go @@ -98,7 +98,7 @@ func (s *Server) loadTemplate(name, path string) (*template.Renderer, error) { return nil, fmt.Errorf("failed to read template file %s: %w", templatePath, err) } - renderer, err := template.NewRenderer(string(content)) + renderer, err := template.NewRenderer(name, string(content)) if err != nil { return nil, fmt.Errorf("failed to create renderer: %w", err) } @@ -108,7 +108,7 @@ func (s *Server) loadTemplate(name, path string) (*template.Renderer, error) { } // handleRender is the main endpoint that handles both ban and captcha rendering -// It reads the remediation type from X-CrowdSec-Remediation header (set by HAProxy) +// It reads the remediation type from X-Crowdsec-Remediation header (set by HAProxy) // It looks up host configuration using the Host header to get ban/captcha settings // Security: HAProxy must delete any user-provided headers and set them from transaction variables func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { @@ -162,11 +162,7 @@ func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { renderer = s.banRenderer statusCode = http.StatusForbidden case "captcha": - if matchedHost == nil { - s.logger.Error("captcha remediation but no host configuration found") - http.Error(w, "Internal Server Error: No host configuration for captcha", http.StatusInternalServerError) - return - } + // matchedHost nil check already done above at line 137-140 renderer = s.captchaRenderer statusCode = http.StatusOK case "allow": diff --git a/pkg/template/renderer.go b/pkg/template/renderer.go index 28c179f..4692393 100644 --- a/pkg/template/renderer.go +++ b/pkg/template/renderer.go @@ -23,10 +23,13 @@ type Renderer struct { } // NewRenderer creates a new template renderer with the given template content -func NewRenderer(templateContent string) (*Renderer, error) { - tmpl, err := template.New("template").Parse(templateContent) +func NewRenderer(name, templateContent string) (*Renderer, error) { + if name == "" { + name = "template" + } + tmpl, err := template.New(name).Parse(templateContent) if err != nil { - return nil, fmt.Errorf("failed to parse template: %w", err) + return nil, fmt.Errorf("failed to parse template %q: %w", name, err) } return &Renderer{ diff --git a/templates/ban.tmpl b/templates/ban.tmpl index f357f15..1c7865c 100644 --- a/templates/ban.tmpl +++ b/templates/ban.tmpl @@ -2,7 +2,7 @@ CrowdSec Ban - + @@ -12,7 +12,7 @@
-

CrowdSec Access Forbidden

@@ -66,9 +66,9 @@ CrowdSec -
-
+ + diff --git a/templates/captcha.tmpl b/templates/captcha.tmpl index 1494a01..685c72e 100644 --- a/templates/captcha.tmpl +++ b/templates/captcha.tmpl @@ -2,7 +2,7 @@ CrowdSec Captcha - + @@ -12,7 +12,7 @@
-

CrowdSec Captcha

@@ -63,7 +63,7 @@ CrowdSec -
+
@@ -12,7 +12,7 @@
-

CrowdSec Captcha

@@ -69,7 +69,7 @@

CrowdSec Captcha