Skip to content

Commit ffe9896

Browse files
authored
feat(http): Implement HTTP request body size limit middleware (#105)
* chore(conductor): Add new track 'Add HTTP Request Body Size Limit Middleware' * feat(config): Add MaxRequestBodySize configuration * conductor(plan): Mark task 'Update Application Configuration' as complete * conductor(checkpoint): Checkpoint end of Phase 1: Configuration Updates * conductor(plan): Mark phase 'Phase 1: Configuration Updates' as complete * feat(http): Implement MaxRequestBodySizeMiddleware * conductor(plan): Mark task 'Create Request Body Size Middleware' as complete * conductor(checkpoint): Checkpoint end of Phase 2: Middleware Implementation * conductor(plan): Mark phase 'Phase 2: Middleware Implementation' as complete * feat(http): Integrate MaxRequestBodySizeMiddleware into router * conductor(plan): Mark task 'Integrate Middleware into Router' as complete * test(integration): Add MaxRequestBodySize to integration test config * conductor(checkpoint): Checkpoint end of Phase 3: Global Integration * conductor(plan): Mark phase 'Phase 3: Global Integration' as complete * chore(conductor): Mark track 'Add HTTP Request Body Size Limit Middleware' as complete * docs(conductor): Synchronize docs for track 'Add HTTP Request Body Size Limit Middleware' * chore(conductor): Archive track 'Add HTTP Request Body Size Limit Middleware' * feat(http): Implement HTTP request body size limit middleware Add a global middleware to limit the size of incoming request bodies, improving system resilience against Denial-of-Service (DoS) attacks. Changes: - Add MaxRequestBodySize configuration (default 1MB) via MAX_REQUEST_BODY_SIZE environment variable. - Implement MaxRequestBodySizeMiddleware in internal/http using http.MaxBytesReader. - Wrap request body to intercept http.MaxBytesError and return 413 Payload Too Large. - Integrate the middleware into the global Gin router in SetupRouter. - Update integration test helpers to include the new limit in test configurations. - Document the new security feature in conductor/tech-stack.md.
1 parent e3018fe commit ffe9896

13 files changed

Lines changed: 259 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Track http_body_size_limit_20260305 Context
2+
3+
- [Specification](./spec.md)
4+
- [Implementation Plan](./plan.md)
5+
- [Metadata](./metadata.json)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"track_id": "http_body_size_limit_20260305",
3+
"type": "feature",
4+
"status": "new",
5+
"created_at": "2026-03-05T00:00:00Z",
6+
"updated_at": "2026-03-05T00:00:00Z",
7+
"description": "Add HTTP Request Body Size Limit Middleware"
8+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Implementation Plan: HTTP Request Body Size Limit Middleware
2+
3+
## Phase 1: Configuration Updates [checkpoint: 0f364c8]
4+
- [x] Task: Update Application Configuration 26ce68b
5+
- [x] Add `MaxRequestBodySize` to the main configuration struct.
6+
- [x] Update config parsing to read `MAX_REQUEST_BODY_SIZE` from environment variables, defaulting to 1 MB (1048576 bytes).
7+
- [x] Write unit tests to verify configuration loading and defaults.
8+
- [x] Task: Conductor - User Manual Verification 'Phase 1: Configuration Updates' (Protocol in workflow.md) 0f364c8
9+
10+
## Phase 2: Middleware Implementation [checkpoint: 21f74a7]
11+
- [x] Task: Create Request Body Size Middleware 6695e3c
12+
- [x] Write failing unit tests for a new middleware that enforces a maximum body size (e.g., verifying 413 response for large payloads, 200 for small ones).
13+
- [x] Implement the middleware in `internal/http/middleware.go` (or a dedicated `body_limit.go` in the same package) using `http.MaxBytesReader` or standard Gin mechanisms.
14+
- [x] Ensure the middleware uses the standard `413 Payload Too Large` error format.
15+
- [x] Run tests to ensure they pass.
16+
- [x] Task: Conductor - User Manual Verification 'Phase 2: Middleware Implementation' (Protocol in workflow.md) 21f74a7
17+
18+
## Phase 3: Global Integration [checkpoint: 7dc251a]
19+
- [x] Task: Integrate Middleware into Router 162ae19
20+
- [x] Add the body limit middleware to the global Gin router in `internal/http/server.go` (or where the global router is instantiated).
21+
- [x] Update any necessary server integration tests to accommodate the middleware.
22+
- [x] Task: Conductor - User Manual Verification 'Phase 3: Global Integration' (Protocol in workflow.md) 7dc251a
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Specification: HTTP Request Body Size Limit Middleware
2+
3+
## Overview
4+
This track introduces an HTTP middleware to limit the size of incoming request bodies. This is a crucial security enhancement to prevent Denial-of-Service (DoS) attacks caused by excessively large payloads.
5+
6+
## Functional Requirements
7+
- **Middleware Implementation:** Create a Gin middleware that intercepts incoming HTTP requests.
8+
- **Size Limitation:** The middleware must restrict the request body size. If the size exceeds the limit, it must immediately return a standard `413 Payload Too Large` HTTP response.
9+
- **Global Application:** The size limit must apply globally to all HTTP routes.
10+
- **Configuration:** The maximum body size must be configurable via an environment variable (e.g., `MAX_REQUEST_BODY_SIZE`).
11+
- **Default Limit:** If the environment variable is not provided, the default maximum request body size should be 1 MB.
12+
13+
## Non-Functional Requirements
14+
- **Performance:** The middleware must evaluate the request size efficiently with minimal overhead.
15+
- **Security:** Prevents resource exhaustion attacks (OOM, excessive disk/CPU usage) from large payloads.
16+
17+
## Acceptance Criteria
18+
- [ ] The middleware is implemented and integrated into the global Gin router.
19+
- [ ] Requests with bodies smaller than or equal to the limit are processed normally.
20+
- [ ] Requests with bodies exceeding the limit are rejected with a standard `413 Payload Too Large` status code.
21+
- [ ] The size limit can be configured via an environment variable.
22+
- [ ] If no environment variable is provided, the limit defaults to 1 MB.
23+
- [ ] Unit tests verify both successful requests and rejected oversized requests.
24+
25+
## Out of Scope
26+
- Custom route-specific limits or exemptions.
27+
- Advanced streaming size limits beyond standard `http.MaxBytesReader` or equivalent Gin mechanics.

conductor/tech-stack.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
## Cryptography & Security
1414
- **Envelope Encryption:** [gocloud.dev/secrets](https://gocloud.dev/howto/secrets/) - Abstracted access to various KMS providers for root-of-trust encryption.
1515
- **Password Hashing:** [go-pwdhash](https://github.com/allisson/go-pwdhash) - Argon2id hashing for secure storage of client secrets and passwords.
16+
- **Request Body Size Limiting:** Middleware to prevent DoS attacks from large payloads.
1617
- **Audit Signing:** HMAC-SHA256 for tamper-evident cryptographic audit logs.
1718

1819
## KMS Providers (Native Support)

conductor/tracks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Project Tracks
22

3-
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
3+
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const (
4141
DefaultMetricsPort = 8081
4242
DefaultLockoutMaxAttempts = 10
4343
DefaultLockoutDuration = 30 // minutes
44+
DefaultMaxRequestBodySize = 1048576
4445
)
4546

4647
// Config holds all application configuration.
@@ -110,6 +111,8 @@ type Config struct {
110111
LockoutMaxAttempts int
111112
// LockoutDuration is the duration for which an account is locked out after maximum attempts.
112113
LockoutDuration time.Duration
114+
// MaxRequestBodySize is the maximum size of the request body in bytes.
115+
MaxRequestBodySize int64
113116
}
114117

115118
// Validate checks if the configuration is valid.
@@ -166,6 +169,7 @@ func (c *Config) Validate() error {
166169
&c.RateLimitTokenRequestsPerSec,
167170
validation.When(c.RateLimitTokenEnabled, validation.Required, validation.Min(0.1)),
168171
),
172+
validation.Field(&c.MaxRequestBodySize, validation.Required, validation.Min(int64(1))),
169173
)
170174
}
171175

@@ -252,6 +256,9 @@ func Load() (*Config, error) {
252256
// Account Lockout
253257
LockoutMaxAttempts: env.GetInt("LOCKOUT_MAX_ATTEMPTS", DefaultLockoutMaxAttempts),
254258
LockoutDuration: env.GetDuration("LOCKOUT_DURATION_MINUTES", DefaultLockoutDuration, time.Minute),
259+
260+
// Request Body Size
261+
MaxRequestBodySize: env.GetInt64("MAX_REQUEST_BODY_SIZE", DefaultMaxRequestBodySize),
255262
}
256263

257264
// Validate configuration

internal/config/config_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func TestConfig_Validate(t *testing.T) {
3131
RateLimitRequestsPerSec: 10,
3232
RateLimitTokenEnabled: true,
3333
RateLimitTokenRequestsPerSec: 5,
34+
MaxRequestBodySize: 1048576,
3435
},
3536
wantErr: false,
3637
},
@@ -100,6 +101,7 @@ func TestConfig_Validate(t *testing.T) {
100101
ServerReadTimeout: 15 * time.Second,
101102
ServerWriteTimeout: 15 * time.Second,
102103
ServerIdleTimeout: 60 * time.Second,
104+
MaxRequestBodySize: 1048576,
103105
KMSKeyURI: "gcpkms://...",
104106
},
105107
wantErr: true,
@@ -115,6 +117,7 @@ func TestConfig_Validate(t *testing.T) {
115117
ServerReadTimeout: 15 * time.Second,
116118
ServerWriteTimeout: 15 * time.Second,
117119
ServerIdleTimeout: 60 * time.Second,
120+
MaxRequestBodySize: 1048576,
118121
KMSProvider: "google",
119122
},
120123
wantErr: true,
@@ -146,6 +149,7 @@ func TestConfig_Validate(t *testing.T) {
146149
ServerReadTimeout: 15 * time.Second,
147150
ServerWriteTimeout: 15 * time.Second,
148151
ServerIdleTimeout: 60 * time.Second,
152+
MaxRequestBodySize: 1048576,
149153
KMSProvider: "invalid_provider",
150154
KMSKeyURI: "invalid://key",
151155
},
@@ -162,6 +166,7 @@ func TestConfig_Validate(t *testing.T) {
162166
ServerReadTimeout: 15 * time.Second,
163167
ServerWriteTimeout: 15 * time.Second,
164168
ServerIdleTimeout: 60 * time.Second,
169+
MaxRequestBodySize: 1048576,
165170
KMSProvider: "localsecrets",
166171
KMSKeyURI: "base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4=",
167172
},
@@ -178,6 +183,7 @@ func TestConfig_Validate(t *testing.T) {
178183
ServerReadTimeout: 15 * time.Second,
179184
ServerWriteTimeout: 15 * time.Second,
180185
ServerIdleTimeout: 60 * time.Second,
186+
MaxRequestBodySize: 1048576,
181187
KMSProvider: "gcpkms",
182188
KMSKeyURI: "gcpkms://projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key",
183189
},
@@ -194,6 +200,7 @@ func TestConfig_Validate(t *testing.T) {
194200
ServerReadTimeout: 15 * time.Second,
195201
ServerWriteTimeout: 15 * time.Second,
196202
ServerIdleTimeout: 60 * time.Second,
203+
MaxRequestBodySize: 1048576,
197204
KMSProvider: "awskms",
198205
KMSKeyURI: "awskms://arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012?region=us-east-1",
199206
},
@@ -210,6 +217,7 @@ func TestConfig_Validate(t *testing.T) {
210217
ServerReadTimeout: 15 * time.Second,
211218
ServerWriteTimeout: 15 * time.Second,
212219
ServerIdleTimeout: 60 * time.Second,
220+
MaxRequestBodySize: 1048576,
213221
KMSProvider: "azurekeyvault",
214222
KMSKeyURI: "azurekeyvault://myvault.vault.azure.net/keys/mykey",
215223
},
@@ -226,6 +234,7 @@ func TestConfig_Validate(t *testing.T) {
226234
ServerReadTimeout: 15 * time.Second,
227235
ServerWriteTimeout: 15 * time.Second,
228236
ServerIdleTimeout: 60 * time.Second,
237+
MaxRequestBodySize: 1048576,
229238
KMSProvider: "hashivault",
230239
KMSKeyURI: "hashivault://mykey",
231240
},
@@ -242,6 +251,7 @@ func TestConfig_Validate(t *testing.T) {
242251
ServerReadTimeout: 15 * time.Second,
243252
ServerWriteTimeout: 15 * time.Second,
244253
ServerIdleTimeout: 60 * time.Second,
254+
MaxRequestBodySize: 1048576,
245255
},
246256
wantErr: false,
247257
},
@@ -256,6 +266,7 @@ func TestConfig_Validate(t *testing.T) {
256266
ServerReadTimeout: 1 * time.Second,
257267
ServerWriteTimeout: 1 * time.Second,
258268
ServerIdleTimeout: 1 * time.Second,
269+
MaxRequestBodySize: 1048576,
259270
},
260271
wantErr: false,
261272
},
@@ -270,6 +281,7 @@ func TestConfig_Validate(t *testing.T) {
270281
ServerReadTimeout: 300 * time.Second,
271282
ServerWriteTimeout: 300 * time.Second,
272283
ServerIdleTimeout: 300 * time.Second,
284+
MaxRequestBodySize: 1048576,
273285
},
274286
wantErr: false,
275287
},
@@ -284,6 +296,7 @@ func TestConfig_Validate(t *testing.T) {
284296
ServerReadTimeout: 0 * time.Second,
285297
ServerWriteTimeout: 15 * time.Second,
286298
ServerIdleTimeout: 60 * time.Second,
299+
MaxRequestBodySize: 1048576,
287300
},
288301
wantErr: true,
289302
},
@@ -298,6 +311,7 @@ func TestConfig_Validate(t *testing.T) {
298311
ServerReadTimeout: 15 * time.Second,
299312
ServerWriteTimeout: 301 * time.Second,
300313
ServerIdleTimeout: 60 * time.Second,
314+
MaxRequestBodySize: 1048576,
301315
},
302316
wantErr: true,
303317
},
@@ -315,6 +329,21 @@ func TestConfig_Validate(t *testing.T) {
315329
},
316330
wantErr: true,
317331
},
332+
{
333+
name: "invalid max request body size - zero",
334+
cfg: &Config{
335+
DBDriver: "postgres",
336+
DBConnectionString: "postgres://localhost",
337+
ServerPort: 8080,
338+
MetricsPort: 8081,
339+
LogLevel: "info",
340+
ServerReadTimeout: 15 * time.Second,
341+
ServerWriteTimeout: 15 * time.Second,
342+
ServerIdleTimeout: 60 * time.Second,
343+
MaxRequestBodySize: 0,
344+
},
345+
wantErr: true,
346+
},
318347
}
319348

320349
for _, tt := range tests {
@@ -363,6 +392,16 @@ func TestLoad(t *testing.T) {
363392
assert.Equal(t, 15*time.Second, cfg.ServerReadTimeout)
364393
assert.Equal(t, 15*time.Second, cfg.ServerWriteTimeout)
365394
assert.Equal(t, 60*time.Second, cfg.ServerIdleTimeout)
395+
assert.Equal(t, int64(1048576), cfg.MaxRequestBodySize)
396+
},
397+
},
398+
{
399+
name: "load custom body size limit",
400+
envVars: map[string]string{
401+
"MAX_REQUEST_BODY_SIZE": "2097152",
402+
},
403+
validate: func(t *testing.T, cfg *Config) {
404+
assert.Equal(t, int64(2097152), cfg.MaxRequestBodySize)
366405
},
367406
},
368407
{

internal/http/middleware.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
package http
33

44
import (
5+
"errors"
6+
"io"
57
"log/slog"
68
"net/http"
79
"time"
@@ -62,3 +64,34 @@ func CustomRecoveryMiddleware(logger *slog.Logger) gin.HandlerFunc {
6264
c.Next()
6365
}
6466
}
67+
68+
// MaxRequestBodySizeMiddleware restricts the request body size.
69+
func MaxRequestBodySizeMiddleware(limit int64) gin.HandlerFunc {
70+
return func(c *gin.Context) {
71+
if c.Request.ContentLength > limit {
72+
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
73+
return
74+
}
75+
c.Request.Body = &maxBytesBody{
76+
ctx: c,
77+
ReadCloser: http.MaxBytesReader(c.Writer, c.Request.Body, limit),
78+
}
79+
c.Next()
80+
}
81+
}
82+
83+
type maxBytesBody struct {
84+
ctx *gin.Context
85+
io.ReadCloser
86+
}
87+
88+
func (b *maxBytesBody) Read(p []byte) (n int, err error) {
89+
n, err = b.ReadCloser.Read(p)
90+
if err != nil {
91+
var maxBytesErr *http.MaxBytesError
92+
if errors.As(err, &maxBytesErr) {
93+
b.ctx.AbortWithStatus(http.StatusRequestEntityTooLarge)
94+
}
95+
}
96+
return n, err
97+
}

internal/http/middleware_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package http
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"io"
67
"log/slog"
@@ -88,3 +89,65 @@ func TestRequestIDMiddleware_HeaderPresent(t *testing.T) {
8889
require.NoError(t, err, "X-Request-Id should be a valid UUID")
8990
assert.NotEqual(t, uuid.Nil, parsedUUID, "X-Request-Id should not be nil UUID")
9091
}
92+
93+
// TestMaxRequestBodySizeMiddleware tests the request body size limit middleware.
94+
func TestMaxRequestBodySizeMiddleware(t *testing.T) {
95+
gin.SetMode(gin.TestMode)
96+
97+
tests := []struct {
98+
name string
99+
limit int64
100+
requestBody string
101+
expectedStatus int
102+
}{
103+
{
104+
name: "within limit",
105+
limit: 10,
106+
requestBody: "small",
107+
expectedStatus: http.StatusOK,
108+
},
109+
{
110+
name: "at limit",
111+
limit: 10,
112+
requestBody: "0123456789",
113+
expectedStatus: http.StatusOK,
114+
},
115+
{
116+
name: "exceeds limit",
117+
limit: 5,
118+
requestBody: "too large",
119+
expectedStatus: http.StatusRequestEntityTooLarge,
120+
},
121+
{
122+
name: "no content length exceeds limit",
123+
limit: 5,
124+
requestBody: "too large",
125+
expectedStatus: http.StatusRequestEntityTooLarge,
126+
},
127+
}
128+
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
router := gin.New()
132+
router.Use(MaxRequestBodySizeMiddleware(tt.limit))
133+
router.POST("/test", func(c *gin.Context) {
134+
// We need to read the body to trigger MaxBytesReader
135+
_, err := io.ReadAll(c.Request.Body)
136+
if err != nil {
137+
return
138+
}
139+
c.Status(http.StatusOK)
140+
})
141+
142+
w := httptest.NewRecorder()
143+
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(tt.requestBody))
144+
if tt.name == "no content length exceeds limit" {
145+
req.ContentLength = -1
146+
req.Header.Del("Content-Length")
147+
}
148+
router.ServeHTTP(w, req)
149+
150+
assert.Equal(t, tt.expectedStatus, w.Code)
151+
})
152+
}
153+
}

0 commit comments

Comments
 (0)