Skip to content

Commit

Permalink
Enforce AppSec rule evaluation results
Browse files Browse the repository at this point in the history
This commit ensures that responses from the CrowdSec AppSec
remediation component are properly evaluated and acted upon.

The `AppSec` support can be enabled either as a dedicated handler
by configuring a route to have the `appsec` directive. That will
use the `http.handlers.appsec` module. It also enables `AppSec`
checks on the  the HTTP handler configured through the `crowdsec`
directive using the `http.handlers.crowdsec` module by default.

In a future commit `AppSec` support will be enabled based on
configuration on the `http.handlers.crowdsec` component.
  • Loading branch information
hslatman committed Nov 15, 2024
1 parent 9d3001c commit 5e67010
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 72 deletions.
30 changes: 24 additions & 6 deletions appsec/appsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions crowdsec/caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
78 changes: 25 additions & 53 deletions http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"errors"
"fmt"
"net/http"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
Expand All @@ -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"
)

Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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)
}
}
}

Expand All @@ -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)
Expand Down
54 changes: 42 additions & 12 deletions internal/bouncer/appsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))

Expand All @@ -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")
}
6 changes: 5 additions & 1 deletion internal/bouncer/bouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,6 +129,8 @@ func (b *Bouncer) Init() (err error) {
return err
}

b.logAppSecStatus()

return nil
}

Expand All @@ -142,6 +144,8 @@ func (b *Bouncer) Init() (err error) {
return err
}

b.logAppSecStatus()

return nil
}

Expand Down
4 changes: 4 additions & 0 deletions internal/bouncer/decisions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
12 changes: 12 additions & 0 deletions internal/bouncer/errors.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit 5e67010

Please sign in to comment.