Merge remote-tracking branch 'main/main'

This commit is contained in:
maxlerebourg
2026-07-25 19:17:47 +02:00
4 changed files with 75 additions and 15 deletions
+12 -3
View File
@@ -733,7 +733,17 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
// read the body of an HTTP/2+ request that has no Content-Length.
func isBodyUnreadable(httpReq *http.Request) bool {
return httpReq.Body != nil && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
return httpReq.Body != nil && httpReq.Body != http.NoBody && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
}
// isMethodWithBody used only when isBodyUnreadable returns true but the request method can't have body.
func isMethodWithBody(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
@@ -745,8 +755,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
var req *http.Request
switch {
case isBodyUnreadable(httpReq):
if bouncer.appsecUnreadableBodyBlock {
// The caller (handleNextServeHTTP) logs this returned error with the IP.
if bouncer.appsecUnreadableBodyBlock && isMethodWithBody(httpReq.Method) {
return errors.New("appsecQuery:unreadableBody dropped")
}
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
+53 -11
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"text/template"
"time"
@@ -396,29 +397,27 @@ func (b blockingBody) Read(_ []byte) (int, error) {
func (blockingBody) Close() error { return nil }
func Test_isBodyUnreadable(t *testing.T) {
realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) }
tests := []struct {
name string
protoMajor int
contentLength int64
hasBody bool
body io.ReadCloser
want bool
}{
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, hasBody: true, want: true},
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, hasBody: true, want: true},
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, hasBody: true, want: false},
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, hasBody: true, want: false},
{name: "http2 without body", protoMajor: 2, contentLength: -1, hasBody: false, want: false},
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true},
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true},
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false},
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false},
{name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false},
{name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
req.ProtoMajor = tt.protoMajor
req.ContentLength = tt.contentLength
if tt.hasBody {
req.Body = http.NoBody
} else {
req.Body = nil
}
req.Body = tt.body
if got := isBodyUnreadable(req); got != tt.want {
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
}
@@ -513,3 +512,46 @@ func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
func newUnreadableGetRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", blockingBody{done: done})
req.ProtoMajor = 3
req.ContentLength = -1
return req
}
// Test_appsecQuery_unreadableBodyGetNotDropped is a regression test for issue #351
func Test_appsecQuery_unreadableBodyGetNotDropped(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newUnreadableGetRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on an HTTP/3 GET without content-length returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on an HTTP/3 GET request body (issue #351 regression)")
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ http:
bouncer:
enabled: "true"
# IP bouncing disabled — this scenario exercises AppSec only.
crowdsecMode: none
crowdsecMode: appsec
crowdsecLapiScheme: http
crowdsecLapiHost: "@@LAPI_HOST@@"
crowdsecLapiKey: "@@APIKEY@@"
+9
View File
@@ -30,6 +30,15 @@ body() {
echo "[$SCENARIO] request that send bad body before crowdsecAppsecBodyLimit must pass (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" -X POST -d "a=0&______"
echo "[$SCENARIO] request http2 that send no body GET (AppSec 200)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:"
echo "[$SCENARIO] request http2 that send unreadable body GET (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -d "test"
echo "[$SCENARIO] request http2 that send unreadable body POST (AppSec 403)"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.4" --http2-prior-knowledge -H "Content-Length:" -X POST -d "test"
}
run_scenario "$SCENARIO" "$HERE" body