Skip to content

Commit

Permalink
Enforce AppSec component 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 2331321
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 2331321

Please sign in to comment.