Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions pkg/acquisition/modules/appsec/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
log "github.com/sirupsen/logrus"

"github.com/crowdsecurity/crowdsec/pkg/acquisition/configuration"
"github.com/crowdsecurity/crowdsec/pkg/acquisition/modules/appsec/httpserver"
"github.com/crowdsecurity/crowdsec/pkg/apiclient/useragent"
"github.com/crowdsecurity/crowdsec/pkg/appsec"
"github.com/crowdsecurity/crowdsec/pkg/appsec/allowlists"
Expand Down Expand Up @@ -156,16 +157,11 @@ func (w *Source) Configure(ctx context.Context, yamlConfig []byte, logger *log.E

w.mux = http.NewServeMux()

w.server = &http.Server{
Addr: w.config.ListenAddr,
Handler: w.mux,
Protocols: &http.Protocols{},
w.server = &httpserver.Server{
Handler: w.mux,
Logger: w.logger,
}

w.server.Protocols.SetHTTP1(true)
w.server.Protocols.SetUnencryptedHTTP2(true)
w.server.Protocols.SetHTTP2(true)

w.InChan = make(chan appsec.ParsedRequest)
appsecCfg := appsec.AppsecConfig{Logger: w.logger.WithField("component", "appsec_config")}

Expand Down
107 changes: 107 additions & 0 deletions pkg/acquisition/modules/appsec/httpserver/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package httpserver_test

// End-to-end benchmark comparing the lenient appsec httpserver against
// net/http's server on the same handler, same wire format, same payload.
//
// Run:
//
// go test -bench=. -benchmem -count=3 \
// ./pkg/acquisition/modules/appsec/httpserver/
//
// Add -cpuprofile=cpu.out / -memprofile=mem.out to capture profiles, then
// inspect with `go tool pprof`.

import (
"bufio"
"io"
"net"
"net/http"
"strconv"
"strings"
"testing"

"github.com/crowdsecurity/crowdsec/pkg/acquisition/modules/appsec/httpserver"
)

// benchHandler mimics the cheap path of appsecHandler: drain the body and
// write a small JSON-shaped response. It deliberately does nothing else so
// the benchmark measures transport cost, not Coraza.
func benchHandler(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"action":"allow"}`))
}

type serverStarter func(net.Listener, http.Handler) (stop func())

func startLenient(l net.Listener, h http.Handler) func() {
s := &httpserver.Server{Handler: h}
go func() { _ = s.Serve(l) }()
return func() { _ = s.Close() }
}

func startStd(l net.Listener, h http.Handler) func() {
s := &http.Server{Handler: h}
go func() { _ = s.Serve(l) }()
return func() { _ = s.Close() }
}

// buildRequest produces a representative bouncer→crowdsec request: the headers
// the bouncer always sets plus a body of the requested size.
func buildRequest(bodySize int) string {
body := strings.Repeat("a", bodySize)
var b strings.Builder
b.WriteString("POST / HTTP/1.1\r\n")
b.WriteString("Host: x\r\n")
b.WriteString("X-Crowdsec-Appsec-Ip: 1.2.3.4\r\n")
b.WriteString("X-Crowdsec-Appsec-Uri: /foo\r\n")
b.WriteString("X-Crowdsec-Appsec-Verb: POST\r\n")
b.WriteString("X-Crowdsec-Appsec-Api-Key: bench\r\n")
b.WriteString("Content-Length: ")
b.WriteString(strconv.Itoa(len(body)))
b.WriteString("\r\n\r\n")
b.WriteString(body)
return b.String()
}

func runBench(b *testing.B, start serverStarter, bodySize int) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatal(err)
}
stop := start(l, http.HandlerFunc(benchHandler))
defer stop()

req := buildRequest(bodySize)
addr := l.Addr().String()

b.ReportAllocs()
b.SetBytes(int64(len(req)))
b.ResetTimer()

b.RunParallel(func(pb *testing.PB) {
c, err := net.Dial("tcp", addr)
if err != nil {
b.Fatal(err)
}
defer c.Close()
br := bufio.NewReader(c)
for pb.Next() {
if _, err := io.WriteString(c, req); err != nil {
b.Fatal(err)
}
resp, err := http.ReadResponse(br, nil)
if err != nil {
b.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
})
}

func BenchmarkServer_Lenient_SmallBody(b *testing.B) { runBench(b, startLenient, 100) }
func BenchmarkServer_Std_SmallBody(b *testing.B) { runBench(b, startStd, 100) }
func BenchmarkServer_Lenient_LargeBody(b *testing.B) { runBench(b, startLenient, 8*1024) }
func BenchmarkServer_Std_LargeBody(b *testing.B) { runBench(b, startStd, 8*1024) }
192 changes: 192 additions & 0 deletions pkg/acquisition/modules/appsec/httpserver/body.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package httpserver

import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)

var (
errInvalidContentLength = errors.New("invalid Content-Length")
errMalformedChunkSize = errors.New("malformed chunk size")
errMalformedChunk = errors.New("missing CRLF after chunk data")
)

// bodyInfo describes how the request body is framed on the wire.
type bodyInfo struct {
Body io.ReadCloser
ContentLength int64
TransferEncoding []string
Chunked bool
}

// newBodyReader builds an io.ReadCloser bounded by Transfer-Encoding /
// Content-Length so the server never relies on the connection EOF for framing.
//
// If both Transfer-Encoding: chunked and Content-Length are present, chunked
// wins (per RFC 7230 §3.3.3) and Content-Length is dropped. When chunked is
// used, the returned reader does NOT consume the trailer section — the caller
// must drain it before reading the next request.
func newBodyReader(src *bufio.Reader, headers http.Header) (bodyInfo, error) {
te := parseTransferEncoding(headers.Get("Transfer-Encoding"))
chunked := len(te) > 0 && te[len(te)-1] == "chunked"
if chunked {
headers.Del("Content-Length")
return bodyInfo{
Body: &chunkedBody{r: newChunkedReader(src)},
ContentLength: -1,
TransferEncoding: te,
Chunked: true,
}, nil
}

cls := headers.Values("Content-Length")
if len(cls) == 0 {
return bodyInfo{
Body: http.NoBody,
ContentLength: 0,
TransferEncoding: te,
}, nil
}
cl, err := strconv.ParseInt(strings.TrimSpace(cls[0]), 10, 64)
if err != nil || cl < 0 {
return bodyInfo{}, errInvalidContentLength
}
if cl == 0 {
return bodyInfo{
Body: http.NoBody,
ContentLength: 0,
TransferEncoding: te,
}, nil
}
return bodyInfo{
Body: &fixedBody{r: src, remaining: cl},
ContentLength: cl,
TransferEncoding: te,
}, nil
}

// fixedBody is a Content-Length bounded ReadCloser. It replaces the
// io.NopCloser(io.LimitReader(...)) chain with a single allocation.
type fixedBody struct {
r *bufio.Reader
remaining int64
}

func (b *fixedBody) Read(p []byte) (int, error) {
if b.remaining <= 0 {
return 0, io.EOF
}
if int64(len(p)) > b.remaining {
p = p[:b.remaining]
}
n, err := b.r.Read(p)
b.remaining -= int64(n)
if err == io.EOF && b.remaining > 0 {
err = io.ErrUnexpectedEOF
}
return n, err
}

func (b *fixedBody) Close() error { return nil }

// chunkedBody wraps a chunkedReader in an io.ReadCloser without allocating an
// io.NopCloser indirection.
type chunkedBody struct{ r *chunkedReader }

func (b *chunkedBody) Read(p []byte) (int, error) { return b.r.Read(p) }
func (b *chunkedBody) Close() error { return nil }

func parseTransferEncoding(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if p != "" {
out = append(out, p)
}
}
return out
}

// chunkedReader decodes RFC 7230 chunked transfer-encoding. It returns io.EOF
// when the zero-length terminator chunk is seen. The trailer section (optional
// trailer headers + final CRLF) is NOT consumed.
type chunkedReader struct {
r *bufio.Reader
remaining int64
eof bool
}

const maxChunkSizeLine = 256

func newChunkedReader(r *bufio.Reader) *chunkedReader {
return &chunkedReader{r: r}
}

func (cr *chunkedReader) Read(p []byte) (int, error) {
if cr.eof {
return 0, io.EOF
}
if cr.remaining == 0 {
size, err := cr.readChunkSize()
if err != nil {
return 0, err
}
if size == 0 {
cr.eof = true
return 0, io.EOF
}
cr.remaining = size
}
toRead := min(int64(len(p)), cr.remaining)
n, err := cr.r.Read(p[:toRead])
cr.remaining -= int64(n)
if err != nil {
return n, err
}
if cr.remaining == 0 {
if err := readCRLF(cr.r); err != nil {
return n, err
}
}
return n, nil
}

func (cr *chunkedReader) readChunkSize() (int64, error) {
line, err := readLine(cr.r, maxChunkSizeLine)
if err != nil {
return 0, err
}
if i := bytes.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
return 0, errMalformedChunkSize
}
size, err := strconv.ParseInt(string(line), 16, 64)
if err != nil || size < 0 {
return 0, fmt.Errorf("%w: %q", errMalformedChunkSize, line)
}
return size, nil
}

func readCRLF(r *bufio.Reader) error {
var buf [2]byte
if _, err := io.ReadFull(r, buf[:2]); err != nil {
return err
}
if buf[0] != '\r' || buf[1] != '\n' {
return errMalformedChunk
}
return nil
}
Loading
Loading