Skip to content

Commit

Permalink
Support setting a maximum AppSec request body size
Browse files Browse the repository at this point in the history
  • Loading branch information
hslatman committed Dec 4, 2024
1 parent 98598e1 commit f7ebfa3
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 15 deletions.
10 changes: 10 additions & 0 deletions crowdsec/caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package crowdsec
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -75,6 +76,15 @@ func parseCrowdSec(d *caddyfile.Dispenser, existingVal any) (any, error) {
return nil, d.ArgErr()
}
cs.AppSecUrl = d.Val()
case "appsec_max_body_bytes":
if !d.NextArg() {
return nil, d.ArgErr()
}
v, err := strconv.Atoi(d.Val())
if err != nil {
return nil, d.Errf("invalid maximum number of bytes %q: %v", d.Val(), err)
}
cs.AppSecMaxBodySize = v
default:
return nil, d.Errf("invalid configuration token %q provided", d.Val())
}
Expand Down
5 changes: 4 additions & 1 deletion crowdsec/crowdsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type CrowdSec struct {
// AppSecUrl is the URL of the AppSec component served by your
// CrowdSec installation. Disabled by default.
AppSecUrl string `json:"appsec_url,omitempty"`
// AppSecMaxBodySize is the maximum number of request body bytes that
// will be sent to your AppSec component.
AppSecMaxBodySize int `json:"appsec_max_body_bytes,omitempty"`

ctx caddy.Context
logger *zap.Logger
Expand All @@ -97,7 +100,7 @@ func (c *CrowdSec) Provision(ctx caddy.Context) error {
c.TickerInterval = "60s"
}

bouncer, err := bouncer.New(c.APIKey, c.APIUrl, c.AppSecUrl, c.TickerInterval, c.logger)
bouncer, err := bouncer.New(c.APIKey, c.APIUrl, c.AppSecUrl, c.AppSecMaxBodySize, c.TickerInterval, c.logger)
if err != nil {
return err
}
Expand Down
30 changes: 19 additions & 11 deletions internal/bouncer/appsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,20 @@ import (
)

type appsec struct {
apiURL string
apiKey string
logger *zap.Logger
client *http.Client
pool *bpool.BufferPool
apiURL string
apiKey string
maxBodySize int
logger *zap.Logger
client *http.Client
pool *bpool.BufferPool
}

func newAppSec(apiURL, apiKey string, logger *zap.Logger) *appsec {
func newAppSec(apiURL, apiKey string, maxBodySize int, logger *zap.Logger) *appsec {
return &appsec{
apiURL: apiURL,
apiKey: apiKey,
logger: logger,
apiURL: apiURL,
apiKey: apiKey,
maxBodySize: maxBodySize,
logger: logger,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Expand Down Expand Up @@ -75,11 +77,17 @@ func (a *appsec) checkRequest(ctx context.Context, r *http.Request) error {
buffer := a.pool.Get()
defer a.pool.Put(buffer)

_, _ = buffer.Write(originalBody)
if a.maxBodySize > 0 {
len := min(len(originalBody), a.maxBodySize)
_, _ = buffer.Write(originalBody[:len])

} else {
_, _ = buffer.Write(originalBody)
}

method = http.MethodPost
contentLength = buffer.Len()
body = io.NopCloser(buffer)
contentLength = buffer.Len()

// "reset" the original request body
r.Body = io.NopCloser(bytes.NewBuffer(originalBody))
Expand Down
128 changes: 128 additions & 0 deletions internal/bouncer/appsec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package bouncer

import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"

"github.com/hslatman/caddy-crowdsec-bouncer/internal/httputils"
)

func newCaddyVarsContext() (ctx context.Context) {
ctx = context.WithValue(context.Background(), caddyhttp.VarsCtxKey, map[string]any{})
return
}

func Test_appsec_checkRequest(t *testing.T) {
logger := zaptest.NewLogger(t)
ctx := newCaddyVarsContext()
caddyhttp.SetVar(ctx, caddyhttp.ClientIPVarKey, "10.0.0.10")
ctx, _ = httputils.EnsureIP(ctx)
noIPCtx := newCaddyVarsContext()

noIPRequest := httptest.NewRequest(http.MethodGet, "/path", http.NoBody)
noIPRequest.Header.Set("User-Agent", "test-appsec")

okGetRequest := httptest.NewRequest(http.MethodGet, "/path", http.NoBody)
okGetRequest.Header.Set("User-Agent", "test-appsec")

okPostRequest := httptest.NewRequest(http.MethodPost, "/path", bytes.NewBufferString("body"))
okPostRequest.Header.Set("User-Agent", "test-appsec")

// TODO: add test for no connection; reading error?
// TODO: add assertions for responses and how they're handled
type fields struct {
maxBodySize int
}
type args struct {
ctx context.Context
r *http.Request
}
tests := []struct {
name string
fields fields
args args
expectedMethod string
expectedBody []byte
wantErr bool
}{
{
name: "ok get",
args: args{
ctx: ctx,
r: okGetRequest,
},
expectedMethod: "GET",
},
{
name: "ok post",
args: args{
ctx: ctx,
r: okPostRequest,
},
expectedMethod: "POST",
expectedBody: []byte("body"),
},
{
name: "ok post limit",
fields: fields{
maxBodySize: 1,
},
args: args{
ctx: ctx,
r: okPostRequest,
},
expectedMethod: "POST",
expectedBody: []byte("b"),
},
{
name: "fail ip",
args: args{
ctx: noIPCtx,
r: noIPRequest,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := http.NewServeMux()
h.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "caddy-cs-bouncer", r.Header.Get("User-Agent"))
assert.Equal(t, "test-appsec", r.Header.Get("X-Crowdsec-Appsec-User-Agent"))
assert.Equal(t, "10.0.0.10", r.Header.Get("X-Crowdsec-Appsec-Ip"))
assert.Equal(t, "/path", r.Header.Get("X-Crowdsec-Appsec-Uri"))
assert.Equal(t, "example.com", r.Header.Get("X-Crowdsec-Appsec-Host"))
assert.Equal(t, tt.expectedMethod, r.Header.Get("X-Crowdsec-Appsec-Verb"))
assert.Equal(t, "test-apikey", r.Header.Get("X-Crowdsec-Appsec-Api-Key"))

if r.Method == http.MethodPost {
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, tt.expectedBody, b)
assert.Equal(t, len(tt.expectedBody), int(r.ContentLength))
}
})

s := httptest.NewServer(h)
t.Cleanup(s.Close)

a := newAppSec(s.URL, "test-apikey", tt.fields.maxBodySize, logger)
err := a.checkRequest(tt.args.ctx, tt.args.r)
if tt.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)
})
}
}
4 changes: 2 additions & 2 deletions internal/bouncer/bouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type Bouncer struct {
}

// New creates a new (streaming) Bouncer with a storage based on immutable radix tree
func New(apiKey, apiURL, appSecURL, tickerInterval string, logger *zap.Logger) (*Bouncer, error) {
func New(apiKey, apiURL, appSecURL string, appSecMaxBodySize int, tickerInterval string, logger *zap.Logger) (*Bouncer, error) {
insecureSkipVerify := false
instantiatedAt := time.Now()
instanceID, err := generateInstanceID(instantiatedAt)
Expand All @@ -88,7 +88,7 @@ func New(apiKey, apiURL, appSecURL, tickerInterval string, logger *zap.Logger) (
InsecureSkipVerify: &insecureSkipVerify,
UserAgent: userAgent,
},
appsec: newAppSec(appSecURL, apiKey, logger.Named("appsec")),
appsec: newAppSec(appSecURL, apiKey, appSecMaxBodySize, logger.Named("appsec")),
store: newStore(),
logger: logger,
instantiatedAt: instantiatedAt,
Expand Down
2 changes: 1 addition & 1 deletion internal/bouncer/bouncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func newBouncer(t *testing.T) (*Bouncer, error) {
tickerInterval := "10s"
logger := zaptest.NewLogger(t)

bouncer, err := New(key, host, "", tickerInterval, logger)
bouncer, err := New(key, host, "", 0, tickerInterval, logger)
require.NoError(t, err)

bouncer.EnableStreaming()
Expand Down

0 comments on commit f7ebfa3

Please sign in to comment.