Skip to content

Commit 54a0dfe

Browse files
committed
Merge commit from fork
* lapi: enforce a maximum body size * add tests
1 parent b381500 commit 54a0dfe

3 files changed

Lines changed: 167 additions & 5 deletions

File tree

pkg/apiserver/body_limit_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package apiserver
2+
3+
import (
4+
"bytes"
5+
"compress/gzip"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
15+
)
16+
17+
// When the MaxBytesReader cap is exceeded, encoding/json surfaces this error
18+
// string from the underlying read. The handlers return it verbatim in the 400
19+
// response, which lets us assert the middleware is the actual rejecter (as
20+
// opposed to a parse or validation error on a truncated body).
21+
const bodyTooLargeMsg = "http: request body too large"
22+
23+
// TestBodyLimit_UnauthenticatedOverLimit posts a JSON document larger than the
24+
// 2 MiB cap on the unauthenticated /v1/watchers endpoint and asserts the
25+
// middleware trips.
26+
func TestBodyLimit_UnauthenticatedOverLimit(t *testing.T) {
27+
ctx := t.Context()
28+
router, _ := NewAPITest(t, ctx)
29+
30+
body := oversizedJSON(t, int(middlewares.UnauthenticatedBodyLimit)+1024)
31+
32+
w := httptest.NewRecorder()
33+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", strings.NewReader(body))
34+
require.NoError(t, err)
35+
req.Header.Set("User-Agent", UserAgent)
36+
req.Header.Set("Content-Type", "application/json")
37+
router.ServeHTTP(w, req)
38+
39+
assert.Equal(t, http.StatusBadRequest, w.Code)
40+
assert.Contains(t, w.Body.String(), bodyTooLargeMsg)
41+
}
42+
43+
// TestBodyLimit_UnauthenticatedUnderLimit sends the same style of request but
44+
// well under the 2 MiB cap, so the middleware must not fire. We don't care
45+
// whether registration ultimately succeeds — only that the failure (if any) is
46+
// not a body-size rejection.
47+
func TestBodyLimit_UnauthenticatedUnderLimit(t *testing.T) {
48+
ctx := t.Context()
49+
router, _ := NewAPITest(t, ctx)
50+
51+
// Valid registration payload, definitely below 2 MiB.
52+
body := `{"machine_id":"test","password":"test"}`
53+
54+
w := httptest.NewRecorder()
55+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", strings.NewReader(body))
56+
require.NoError(t, err)
57+
req.Header.Set("User-Agent", UserAgent)
58+
req.Header.Set("Content-Type", "application/json")
59+
router.ServeHTTP(w, req)
60+
61+
assert.NotContains(t, w.Body.String(), bodyTooLargeMsg)
62+
}
63+
64+
// TestBodyLimit_AuthenticatedAboveUnauthCap verifies that an authenticated
65+
// endpoint accepts bodies larger than the unauthenticated cap. The alert
66+
// payload here is not semantically valid, so we don't expect 2xx — but the
67+
// rejection must not come from the body-size middleware.
68+
func TestBodyLimit_AuthenticatedAboveUnauthCap(t *testing.T) {
69+
ctx := t.Context()
70+
lapi := SetupLAPITest(t, ctx)
71+
72+
// Build a payload ~4 MiB: over the unauth 2 MiB cap, well under the 50 MiB
73+
// auth cap. Uses the alert-array shape so we get past the JSON top-level
74+
// type check and into field validation (which will fail — that's fine).
75+
size := int(middlewares.UnauthenticatedBodyLimit) * 2
76+
body := `[{"message":"` + strings.Repeat("a", size) + `"}]`
77+
78+
w := lapi.RecordResponse(t, ctx, http.MethodPost, "/v1/alerts", strings.NewReader(body), passwordAuthType)
79+
80+
assert.NotContains(t, w.Body.String(), bodyTooLargeMsg)
81+
}
82+
83+
// TestBodyLimit_AuthenticatedOverLimit posts a payload above the 50 MiB auth
84+
// cap and asserts the middleware trips.
85+
func TestBodyLimit_AuthenticatedOverLimit(t *testing.T) {
86+
ctx := t.Context()
87+
lapi := SetupLAPITest(t, ctx)
88+
89+
size := int(middlewares.AuthenticatedBodyLimit) + 1024
90+
body := `[{"message":"` + strings.Repeat("a", size) + `"}]`
91+
92+
w := lapi.RecordResponse(t, ctx, http.MethodPost, "/v1/alerts", strings.NewReader(body), passwordAuthType)
93+
94+
assert.Equal(t, http.StatusBadRequest, w.Code)
95+
assert.Contains(t, w.Body.String(), bodyTooLargeMsg)
96+
}
97+
98+
// TestBodyLimit_GzipDecompressedSize confirms the cap is enforced on the
99+
// *decompressed* size: a small compressed payload that expands past the
100+
// unauthenticated cap must be rejected.
101+
func TestBodyLimit_GzipDecompressedSize(t *testing.T) {
102+
ctx := t.Context()
103+
router, _ := NewAPITest(t, ctx)
104+
105+
// Pad a valid-looking JSON doc with a large whitespace run; zeros/spaces
106+
// compress to a tiny payload but expand past the 2 MiB cap.
107+
decompressed := `{"machine_id":"test","password":"test","_pad":"` +
108+
strings.Repeat(" ", int(middlewares.UnauthenticatedBodyLimit)+1024) + `"}`
109+
110+
var compressed bytes.Buffer
111+
gz := gzip.NewWriter(&compressed)
112+
_, err := gz.Write([]byte(decompressed))
113+
require.NoError(t, err)
114+
require.NoError(t, gz.Close())
115+
require.Less(t, compressed.Len(), int(middlewares.UnauthenticatedBodyLimit),
116+
"compressed body must be under the cap so only the decompressed size can trip it")
117+
118+
w := httptest.NewRecorder()
119+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", bytes.NewReader(compressed.Bytes()))
120+
require.NoError(t, err)
121+
req.Header.Set("User-Agent", UserAgent)
122+
req.Header.Set("Content-Type", "application/json")
123+
req.Header.Set("Content-Encoding", "gzip")
124+
router.ServeHTTP(w, req)
125+
126+
assert.Equal(t, http.StatusBadRequest, w.Code)
127+
assert.Contains(t, w.Body.String(), bodyTooLargeMsg)
128+
}
129+
130+
// oversizedJSON returns a syntactically-valid JSON object whose raw size is at
131+
// least `size` bytes, via a long string field.
132+
func oversizedJSON(t *testing.T, size int) string {
133+
t.Helper()
134+
return `{"machine_id":"test","password":"test","_pad":"` + strings.Repeat("a", size) + `"}`
135+
}

pkg/apiserver/controllers/controller.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/gin-gonic/gin"
1111

1212
v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1"
13+
middlewaresv1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
1314
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
1415
"github.com/crowdsecurity/crowdsec/pkg/database"
1516
"github.com/crowdsecurity/crowdsec/pkg/logging"
@@ -110,13 +111,16 @@ func (c *Controller) NewV1() error {
110111
ctx.AbortWithStatus(http.StatusMethodNotAllowed)
111112
})
112113

114+
unauthBodyLimit := middlewaresv1.BodyLimit(middlewaresv1.UnauthenticatedBodyLimit)
115+
authBodyLimit := middlewaresv1.BodyLimit(middlewaresv1.AuthenticatedBodyLimit)
116+
113117
groupV1 := c.Router.Group("/v1")
114-
groupV1.POST("/watchers", c.HandlerV1.AbortRemoteIf(c.DisableRemoteLapiRegistration), c.HandlerV1.CreateMachine)
115-
groupV1.POST("/watchers/login", c.HandlerV1.Middlewares.JWT.Middleware.LoginHandler)
118+
groupV1.POST("/watchers", unauthBodyLimit, c.HandlerV1.AbortRemoteIf(c.DisableRemoteLapiRegistration), c.HandlerV1.CreateMachine)
119+
groupV1.POST("/watchers/login", unauthBodyLimit, c.HandlerV1.Middlewares.JWT.Middleware.LoginHandler)
116120

117121
jwtAuth := groupV1.Group("")
118122
jwtAuth.GET("/refresh_token", c.HandlerV1.Middlewares.JWT.Middleware.RefreshHandler)
119-
jwtAuth.Use(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), v1.PrometheusMachinesMiddleware)
123+
jwtAuth.Use(authBodyLimit, c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), v1.PrometheusMachinesMiddleware)
120124
{
121125
jwtAuth.POST("/alerts", c.HandlerV1.CreateAlert)
122126
jwtAuth.GET("/alerts", c.HandlerV1.FindAlerts)
@@ -137,7 +141,7 @@ func (c *Controller) NewV1() error {
137141
}
138142

139143
apiKeyAuth := groupV1.Group("")
140-
apiKeyAuth.Use(c.HandlerV1.Middlewares.APIKey.Middleware, v1.PrometheusBouncersMiddleware)
144+
apiKeyAuth.Use(authBodyLimit, c.HandlerV1.Middlewares.APIKey.Middleware, v1.PrometheusBouncersMiddleware)
141145
{
142146
apiKeyAuth.GET("/decisions", c.HandlerV1.GetDecision)
143147
apiKeyAuth.HEAD("/decisions", c.HandlerV1.GetDecision)
@@ -146,7 +150,7 @@ func (c *Controller) NewV1() error {
146150
}
147151

148152
eitherAuth := groupV1.Group("")
149-
eitherAuth.Use(eitherAuthMiddleware(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), c.HandlerV1.Middlewares.APIKey.Middleware))
153+
eitherAuth.Use(authBodyLimit, eitherAuthMiddleware(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), c.HandlerV1.Middlewares.APIKey.Middleware))
150154
{
151155
eitherAuth.POST("/usage-metrics", c.HandlerV1.UsageMetrics)
152156
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package v1
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gin-gonic/gin"
7+
)
8+
9+
// Maximum body size for LAPI queries
10+
// Applies to the decompressed body if it's gzipped
11+
const (
12+
UnauthenticatedBodyLimit int64 = 2 * 1024 * 1024 // 2 MiB
13+
AuthenticatedBodyLimit int64 = 50 * 1024 * 1024 // 50 MiB
14+
)
15+
16+
func BodyLimit(max int64) gin.HandlerFunc {
17+
return func(c *gin.Context) {
18+
if c.Request.Body != nil {
19+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max)
20+
}
21+
c.Next()
22+
}
23+
}

0 commit comments

Comments
 (0)