diff --git a/appsec/appsec.go b/appsec/appsec.go index add26410..b7bc83ca 100644 --- a/appsec/appsec.go +++ b/appsec/appsec.go @@ -24,6 +24,8 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/hslatman/caddy-crowdsec-bouncer/crowdsec" + "github.com/hslatman/caddy-crowdsec-bouncer/internal/bouncer" + "github.com/hslatman/caddy-crowdsec-bouncer/internal/utils" "go.uber.org/zap" ) @@ -32,7 +34,8 @@ func init() { httpcaddyfile.RegisterHandlerDirective("appsec", parseCaddyfileHandlerDirective) } -// Handler matches request IPs to CrowdSec decisions to (dis)allow access +// Handler checks the CrowdSec AppSec component decided whether +// an HTTP request is blocked or not. type Handler struct { logger *zap.Logger crowdsec *crowdsec.CrowdSec @@ -46,7 +49,7 @@ func (Handler) CaddyModule() caddy.ModuleInfo { } } -// Provision sets up the CrowdSec handler. +// Provision sets up the CrowdSec AppSec handler. func (h *Handler) Provision(ctx caddy.Context) error { crowdsecAppIface, err := ctx.App("crowdsec") if err != nil { @@ -71,10 +74,25 @@ func (h *Handler) Validate() error { // ServeHTTP is the Caddy handler for serving HTTP requests func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - err := h.crowdsec.CheckRequest(r.Context(), r) - if err != nil { - // TODO: do something with the error - // TODO: add (debug) logging + if err := h.crowdsec.CheckRequest(r.Context(), r); err != nil { + a := &bouncer.AppSecError{} + if !errors.As(err, &a) { + return err + } + + ip, err := utils.DetermineIPFromRequest(r) + if err != nil { + return err // TODO: return error here? Or just log it and continue serving + } + + switch a.Action { + case "allow": + // nothing to do + case "log": + h.logger.Info("appsec rule triggered", zap.String("ip", ip.String()), zap.String("action", a.Action)) + default: + return utils.WriteResponse(w, h.logger, a.Action, ip.String(), a.Duration, a.StatusCode) + } } // Continue down the handler stack diff --git a/crowdsec/caddyfile.go b/crowdsec/caddyfile.go index 53d34aaf..a9e2e505 100644 --- a/crowdsec/caddyfile.go +++ b/crowdsec/caddyfile.go @@ -70,6 +70,11 @@ func parseCrowdSec(d *caddyfile.Dispenser, existingVal any) (any, error) { return nil, d.ArgErr() } cs.EnableHardFails = &tv + case "appsec_url": + if !d.NextArg() { + return nil, d.ArgErr() + } + cs.AppSecUrl = d.Val() default: return nil, d.Errf("invalid configuration token provided: %s", d.Val()) } diff --git a/http/http.go b/http/http.go index bc295ffc..a81a82bc 100644 --- a/http/http.go +++ b/http/http.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -28,6 +27,7 @@ import ( _ "github.com/hslatman/caddy-crowdsec-bouncer/appsec" // include support for AppSec WAF component "github.com/hslatman/caddy-crowdsec-bouncer/crowdsec" + "github.com/hslatman/caddy-crowdsec-bouncer/internal/bouncer" "github.com/hslatman/caddy-crowdsec-bouncer/internal/utils" ) @@ -38,9 +38,8 @@ func init() { // Handler matches request IPs to CrowdSec decisions to (dis)allow access type Handler struct { - logger *zap.Logger - crowdsec *crowdsec.CrowdSec - + logger *zap.Logger + crowdsec *crowdsec.CrowdSec appsecEnabled bool } @@ -60,7 +59,7 @@ func (h *Handler) Provision(ctx caddy.Context) error { } h.crowdsec = crowdsecAppIface.(*crowdsec.CrowdSec) - h.appsecEnabled = true // TODO: move to provisioning; unmarshaling of Caddyfile; etc + h.appsecEnabled = true // TODO: make configurable h.logger = ctx.Logger(h) defer h.logger.Sync() // nolint @@ -80,42 +79,43 @@ func (h *Handler) Validate() error { // ServeHTTP is the Caddy handler for serving HTTP requests func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - ipToCheck, err := utils.DetermineIPFromRequest(r) + ip, err := utils.DetermineIPFromRequest(r) if err != nil { return err // TODO: return error here? Or just log it and continue serving } - isAllowed, decision, err := h.crowdsec.IsAllowed(ipToCheck) + isAllowed, decision, err := h.crowdsec.IsAllowed(ip) if err != nil { return err // TODO: return error here? Or just log it and continue serving } + // TODO: if the IP is allowed, should we (temporarily) put it in an explicit allowlist for quicker check? + if !isAllowed { // TODO: maybe some configuration to override the type of action with a ban, some default, something like that? // TODO: can we provide the reason for the response to the Caddy logger, like the CrowdSec type, duration, etc. typ := *decision.Type - switch typ { - case "ban": - h.logger.Debug(fmt.Sprintf("serving ban response to %s", *decision.Value)) - return writeBanResponse(w) - case "captcha": - h.logger.Debug(fmt.Sprintf("serving captcha (ban) response to %s", *decision.Value)) - return writeCaptchaResponse(w) - case "throttle": - h.logger.Debug(fmt.Sprintf("serving throttle response to %s", *decision.Value)) - return writeThrottleResponse(w, *decision.Duration) - default: - h.logger.Warn(fmt.Sprintf("got crowdsec decision type: %s", typ)) - h.logger.Debug(fmt.Sprintf("serving ban response to %s", *decision.Value)) - return writeBanResponse(w) - } - } + value := *decision.Value + duration := *decision.Duration - // TODO: if the IP is allowed, should we (temporarily) put it in an explicit allowlist for quicker check? + return utils.WriteResponse(w, h.logger, typ, value, duration, 0) + } if h.appsecEnabled { if err := h.crowdsec.CheckRequest(r.Context(), r); err != nil { - // TODO: do something with the error + a := &bouncer.AppSecError{} + if !errors.As(err, &a) { + return err + } + + switch a.Action { + case "allow": + // nothing to do + case "log": + h.logger.Info("appsec rule triggered", zap.String("ip", ip.String()), zap.String("action", a.Action)) + default: + return utils.WriteResponse(w, h.logger, a.Action, ip.String(), a.Duration, a.StatusCode) + } } } @@ -127,34 +127,6 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhtt return nil } -// writeBanResponse writes a 403 status as response -func writeBanResponse(w http.ResponseWriter) error { - w.WriteHeader(http.StatusForbidden) - return nil -} - -// writeCaptchaResponse (currently) writes a 403 status as response -func writeCaptchaResponse(w http.ResponseWriter) error { - // TODO: implement showing a captcha in some way. How? hCaptcha? And how to handle afterwards? - return writeBanResponse(w) -} - -// writeThrottleResponse writes 429 status as response -func writeThrottleResponse(w http.ResponseWriter, duration string) error { - - d, err := time.ParseDuration(duration) - if err != nil { - return err - } - - // TODO: round this to the nearest multiple of the ticker interval? and/or include the time the decision was processed from stream vs. request time? - retryAfter := fmt.Sprintf("%.0f", d.Seconds()) - w.Header().Add("Retry-After", retryAfter) - w.WriteHeader(http.StatusTooManyRequests) - - return nil -} - // UnmarshalCaddyfile implements caddyfile.Unmarshaler. func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // TODO: parse additional handler directives (none exist now) diff --git a/internal/bouncer/appsec.go b/internal/bouncer/appsec.go index bab91aa5..37d86e15 100644 --- a/internal/bouncer/appsec.go +++ b/internal/bouncer/appsec.go @@ -3,39 +3,44 @@ package bouncer import ( "bytes" "context" + "encoding/json" "errors" - "fmt" "io" "net/http" "time" "github.com/hslatman/caddy-crowdsec-bouncer/internal/utils" + "go.uber.org/zap" ) type appsec struct { apiURL string apiKey string + logger *zap.Logger client *http.Client } -func newAppSec(apiURL, apiKey string) *appsec { +func newAppSec(apiURL, apiKey string, logger *zap.Logger) *appsec { return &appsec{ apiURL: apiURL, apiKey: apiKey, + logger: logger, client: &http.Client{ Timeout: 10 * time.Second, }, } } +type appsecResponse struct { + Action string `json:"action"` + StatusCode int `json:"http_status"` +} + func (a *appsec) checkRequest(ctx context.Context, r *http.Request) error { if a.apiURL == "" { - return nil // AppSec component not enabled + return nil // AppSec component not enabled; skip check } - // TODO: add (debug) logging - // TODO: return a decision, and act on it in the handler (named/typed error) - originalIP, err := utils.DetermineIPFromRequest(r) if err != nil { return err // TODO: return error here? Or just log it and continue serving @@ -50,7 +55,7 @@ func (a *appsec) checkRequest(ctx context.Context, r *http.Request) error { var body io.ReadCloser = http.NoBody if len(originalBody) > 0 { method = http.MethodPost - body = io.NopCloser(bytes.NewBuffer(originalBody)) + body = io.NopCloser(bytes.NewBuffer(originalBody)) // TODO: reuse buffers? } r.Body = io.NopCloser(bytes.NewBuffer(originalBody)) @@ -65,21 +70,46 @@ func (a *appsec) checkRequest(ctx context.Context, r *http.Request) error { req.Header.Set("X-Crowdsec-Appsec-Verb", r.Method) req.Header.Set("X-Crowdsec-Appsec-Api-Key", a.apiKey) + totalAppSecCalls.Inc() resp, err := a.client.Do(req) + if err != nil { + totalAppSecErrors.Inc() + return err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) if err != nil { return err } switch resp.StatusCode { case 200: - return nil // TODO: read decision from body? + return nil case 401: - return errors.New("not authenticated") + a.logger.Error("appsec component not authenticated", zap.String("appsec_url", a.apiURL)) + return nil // this fails open, currently; make it fail hard if configured to do so? case 403: - return errors.New("not allowed") // TODO: read decision + var r appsecResponse + if err := json.Unmarshal(responseBody, &r); err != nil { + return err + } + + return &AppSecError{Err: errors.New("appsec rule triggered"), Action: r.Action, Duration: "", StatusCode: r.StatusCode} case 500: - return errors.New("internal error") + a.logger.Error("appsec component internal error", zap.String("appsec_url", a.apiURL)) + return nil // this fails open, currently; make it fail hard if configured to do so? default: - return fmt.Errorf("unsupported status code %d", resp.StatusCode) + a.logger.Warn("appsec component returned unsupported status", zap.String("code", resp.Status)) + return nil + } +} + +func (b *Bouncer) logAppSecStatus() { + if b.appsec.apiURL == "" { + b.logger.Info("appsec disabled") + return } + + b.logger.Info("appsec enabled") } diff --git a/internal/bouncer/bouncer.go b/internal/bouncer/bouncer.go index 7658e710..c577acd1 100644 --- a/internal/bouncer/bouncer.go +++ b/internal/bouncer/bouncer.go @@ -88,7 +88,7 @@ func New(apiKey, apiURL, appSecURL, tickerInterval string, logger *zap.Logger) ( InsecureSkipVerify: &insecureSkipVerify, UserAgent: userAgent, }, - appsec: newAppSec(appSecURL, apiKey), + appsec: newAppSec(appSecURL, apiKey, logger.Named("appsec")), store: newStore(), logger: logger, instantiatedAt: instantiatedAt, @@ -129,6 +129,8 @@ func (b *Bouncer) Init() (err error) { return err } + b.logAppSecStatus() + return nil } @@ -142,6 +144,8 @@ func (b *Bouncer) Init() (err error) { return err } + b.logAppSecStatus() + return nil } diff --git a/internal/bouncer/decisions.go b/internal/bouncer/decisions.go index af2709d6..6865d449 100644 --- a/internal/bouncer/decisions.go +++ b/internal/bouncer/decisions.go @@ -100,18 +100,22 @@ func (b *Bouncer) retrieveDecision(ip netip.Addr) (*models.Decision, error) { return b.store.get(ip) } + totalLAPICalls.Inc() // increment; not built into liveBouncer decision, err := b.liveBouncer.Get(ip.String()) if err != nil { + totalLAPIErrors.Inc() // increment; not built into liveBouncer fields := []zapcore.Field{ b.zapField(), zap.String("address", b.liveBouncer.APIUrl), zap.Error(err), } + if b.shouldFailHard { b.logger.Fatal(err.Error(), fields...) } else { b.logger.Error(err.Error(), fields...) } + return nil, nil // when not failing hard, we return no error } diff --git a/internal/bouncer/errors.go b/internal/bouncer/errors.go new file mode 100644 index 00000000..4ec05cd5 --- /dev/null +++ b/internal/bouncer/errors.go @@ -0,0 +1,12 @@ +package bouncer + +type AppSecError struct { + Err error + Action string + Duration string + StatusCode int +} + +func (a AppSecError) Error() string { + return a.Err.Error() +} diff --git a/internal/bouncer/metrics.go b/internal/bouncer/metrics.go index 8ed72f7b..d73aa83c 100644 --- a/internal/bouncer/metrics.go +++ b/internal/bouncer/metrics.go @@ -9,9 +9,26 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/models" csbouncer "github.com/crowdsecurity/go-cs-bouncer" "github.com/crowdsecurity/go-cs-lib/ptr" + "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" ) +var ( + // metrics provided by the go-cs-bouncer package + totalLAPICalls = csbouncer.TotalLAPICalls + totalLAPIErrors = csbouncer.TotalLAPIError + + // appsec metrics + totalAppSecCalls = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "lapi_appsec_requests_total", + Help: "The total number of calls to CrowdSec LAPI AppSec component", + }) + totalAppSecErrors = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "lapi_appsec_requests_failures_total", + Help: "The total number of failed calls to CrowdSec LAPI AppSec component", + }) +) + func newMetricsProvider(client *apiclient.ApiClient, updater csbouncer.MetricsUpdater, interval time.Duration) (*csbouncer.MetricsProvider, error) { m, err := csbouncer.NewMetricsProvider( client, diff --git a/internal/utils/http.go b/internal/utils/http.go index b7be55a5..cc9584ed 100644 --- a/internal/utils/http.go +++ b/internal/utils/http.go @@ -5,8 +5,10 @@ import ( "fmt" "net/http" "net/netip" + "time" "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "go.uber.org/zap" ) // DetermineIPFromRequest returns the IP of the client based on the value that @@ -37,3 +39,55 @@ func DetermineIPFromRequest(r *http.Request) (netip.Addr, error) { return ip, nil } + +// WriteResponse writes a response to the [http.ResponseWriter] based on the typ, value, +// duration and status code provide. +func WriteResponse(w http.ResponseWriter, logger *zap.Logger, typ, value, duration string, statusCode int) error { + switch typ { + case "ban": + logger.Debug(fmt.Sprintf("serving ban response to %s", value)) + return writeBanResponse(w, statusCode) + case "captcha": + logger.Debug(fmt.Sprintf("serving captcha (ban) response to %s", value)) + return writeCaptchaResponse(w, statusCode) + case "throttle": + logger.Debug(fmt.Sprintf("serving throttle response to %s", value)) + return writeThrottleResponse(w, duration) + default: + logger.Warn(fmt.Sprintf("got crowdsec decision type: %s", typ)) + logger.Debug(fmt.Sprintf("serving ban response to %s", value)) + return writeBanResponse(w, statusCode) + } +} + +// writeBanResponse writes a 403 status as response +func writeBanResponse(w http.ResponseWriter, statusCode int) error { + code := statusCode + if code <= 0 { + code = http.StatusForbidden + } + + w.WriteHeader(code) + return nil +} + +// writeCaptchaResponse (currently) writes a 403 status as response +func writeCaptchaResponse(w http.ResponseWriter, statusCode int) error { + // TODO: implement showing a captcha in some way. How? hCaptcha? And how to handle afterwards? + return writeBanResponse(w, statusCode) +} + +// writeThrottleResponse writes 429 status as response +func writeThrottleResponse(w http.ResponseWriter, duration string) error { + d, err := time.ParseDuration(duration) + if err != nil { + return err + } + + // TODO: round this to the nearest multiple of the ticker interval? and/or include the time the decision was processed from stream vs. request time? + retryAfter := fmt.Sprintf("%.0f", d.Seconds()) + w.Header().Add("Retry-After", retryAfter) + w.WriteHeader(http.StatusTooManyRequests) + + return nil +}