Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4e28127
chore(conductor): Add new track 'Add HTTP Request Body Size Limit Mid…
allisson Mar 5, 2026
26ce68b
feat(config): Add MaxRequestBodySize configuration
allisson Mar 5, 2026
c5255ae
conductor(plan): Mark task 'Update Application Configuration' as comp…
allisson Mar 5, 2026
0f364c8
conductor(checkpoint): Checkpoint end of Phase 1: Configuration Updates
allisson Mar 5, 2026
f407e11
conductor(plan): Mark phase 'Phase 1: Configuration Updates' as complete
allisson Mar 5, 2026
6695e3c
feat(http): Implement MaxRequestBodySizeMiddleware
allisson Mar 5, 2026
0cfb7e9
conductor(plan): Mark task 'Create Request Body Size Middleware' as c…
allisson Mar 5, 2026
21f74a7
conductor(checkpoint): Checkpoint end of Phase 2: Middleware Implemen…
allisson Mar 5, 2026
c0d97b8
conductor(plan): Mark phase 'Phase 2: Middleware Implementation' as c…
allisson Mar 5, 2026
162ae19
feat(http): Integrate MaxRequestBodySizeMiddleware into router
allisson Mar 5, 2026
232414e
conductor(plan): Mark task 'Integrate Middleware into Router' as comp…
allisson Mar 5, 2026
94874bc
test(integration): Add MaxRequestBodySize to integration test config
allisson Mar 5, 2026
7dc251a
conductor(checkpoint): Checkpoint end of Phase 3: Global Integration
allisson Mar 5, 2026
9830ea6
conductor(plan): Mark phase 'Phase 3: Global Integration' as complete
allisson Mar 5, 2026
fe4249d
chore(conductor): Mark track 'Add HTTP Request Body Size Limit Middle…
allisson Mar 5, 2026
b86930e
docs(conductor): Synchronize docs for track 'Add HTTP Request Body Si…
allisson Mar 5, 2026
5ecf7b7
chore(conductor): Archive track 'Add HTTP Request Body Size Limit Mid…
allisson Mar 5, 2026
69058ca
feat(http): Implement HTTP request body size limit middleware
allisson Mar 5, 2026
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
5 changes: 5 additions & 0 deletions conductor/archive/http_body_size_limit_20260305/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Track http_body_size_limit_20260305 Context

- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
8 changes: 8 additions & 0 deletions conductor/archive/http_body_size_limit_20260305/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"track_id": "http_body_size_limit_20260305",
"type": "feature",
"status": "new",
"created_at": "2026-03-05T00:00:00Z",
"updated_at": "2026-03-05T00:00:00Z",
"description": "Add HTTP Request Body Size Limit Middleware"
}
22 changes: 22 additions & 0 deletions conductor/archive/http_body_size_limit_20260305/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Implementation Plan: HTTP Request Body Size Limit Middleware

## Phase 1: Configuration Updates [checkpoint: 0f364c8]
- [x] Task: Update Application Configuration 26ce68b
- [x] Add `MaxRequestBodySize` to the main configuration struct.
- [x] Update config parsing to read `MAX_REQUEST_BODY_SIZE` from environment variables, defaulting to 1 MB (1048576 bytes).
- [x] Write unit tests to verify configuration loading and defaults.
- [x] Task: Conductor - User Manual Verification 'Phase 1: Configuration Updates' (Protocol in workflow.md) 0f364c8

## Phase 2: Middleware Implementation [checkpoint: 21f74a7]
- [x] Task: Create Request Body Size Middleware 6695e3c
- [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).
- [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.
- [x] Ensure the middleware uses the standard `413 Payload Too Large` error format.
- [x] Run tests to ensure they pass.
- [x] Task: Conductor - User Manual Verification 'Phase 2: Middleware Implementation' (Protocol in workflow.md) 21f74a7

## Phase 3: Global Integration [checkpoint: 7dc251a]
- [x] Task: Integrate Middleware into Router 162ae19
- [x] Add the body limit middleware to the global Gin router in `internal/http/server.go` (or where the global router is instantiated).
- [x] Update any necessary server integration tests to accommodate the middleware.
- [x] Task: Conductor - User Manual Verification 'Phase 3: Global Integration' (Protocol in workflow.md) 7dc251a
27 changes: 27 additions & 0 deletions conductor/archive/http_body_size_limit_20260305/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Specification: HTTP Request Body Size Limit Middleware

## Overview
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.

## Functional Requirements
- **Middleware Implementation:** Create a Gin middleware that intercepts incoming HTTP requests.
- **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.
- **Global Application:** The size limit must apply globally to all HTTP routes.
- **Configuration:** The maximum body size must be configurable via an environment variable (e.g., `MAX_REQUEST_BODY_SIZE`).
- **Default Limit:** If the environment variable is not provided, the default maximum request body size should be 1 MB.

## Non-Functional Requirements
- **Performance:** The middleware must evaluate the request size efficiently with minimal overhead.
- **Security:** Prevents resource exhaustion attacks (OOM, excessive disk/CPU usage) from large payloads.

## Acceptance Criteria
- [ ] The middleware is implemented and integrated into the global Gin router.
- [ ] Requests with bodies smaller than or equal to the limit are processed normally.
- [ ] Requests with bodies exceeding the limit are rejected with a standard `413 Payload Too Large` status code.
- [ ] The size limit can be configured via an environment variable.
- [ ] If no environment variable is provided, the limit defaults to 1 MB.
- [ ] Unit tests verify both successful requests and rejected oversized requests.

## Out of Scope
- Custom route-specific limits or exemptions.
- Advanced streaming size limits beyond standard `http.MaxBytesReader` or equivalent Gin mechanics.
1 change: 1 addition & 0 deletions conductor/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
## Cryptography & Security
- **Envelope Encryption:** [gocloud.dev/secrets](https://gocloud.dev/howto/secrets/) - Abstracted access to various KMS providers for root-of-trust encryption.
- **Password Hashing:** [go-pwdhash](https://github.com/allisson/go-pwdhash) - Argon2id hashing for secure storage of client secrets and passwords.
- **Request Body Size Limiting:** Middleware to prevent DoS attacks from large payloads.
- **Audit Signing:** HMAC-SHA256 for tamper-evident cryptographic audit logs.

## KMS Providers (Native Support)
Expand Down
2 changes: 1 addition & 1 deletion conductor/tracks.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Project Tracks

This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (
DefaultMetricsPort = 8081
DefaultLockoutMaxAttempts = 10
DefaultLockoutDuration = 30 // minutes
DefaultMaxRequestBodySize = 1048576
)

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

// Validate checks if the configuration is valid.
Expand Down Expand Up @@ -166,6 +169,7 @@ func (c *Config) Validate() error {
&c.RateLimitTokenRequestsPerSec,
validation.When(c.RateLimitTokenEnabled, validation.Required, validation.Min(0.1)),
),
validation.Field(&c.MaxRequestBodySize, validation.Required, validation.Min(int64(1))),
)
}

Expand Down Expand Up @@ -252,6 +256,9 @@ func Load() (*Config, error) {
// Account Lockout
LockoutMaxAttempts: env.GetInt("LOCKOUT_MAX_ATTEMPTS", DefaultLockoutMaxAttempts),
LockoutDuration: env.GetDuration("LOCKOUT_DURATION_MINUTES", DefaultLockoutDuration, time.Minute),

// Request Body Size
MaxRequestBodySize: env.GetInt64("MAX_REQUEST_BODY_SIZE", DefaultMaxRequestBodySize),
}

// Validate configuration
Expand Down
39 changes: 39 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func TestConfig_Validate(t *testing.T) {
RateLimitRequestsPerSec: 10,
RateLimitTokenEnabled: true,
RateLimitTokenRequestsPerSec: 5,
MaxRequestBodySize: 1048576,
},
wantErr: false,
},
Expand Down Expand Up @@ -100,6 +101,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSKeyURI: "gcpkms://...",
},
wantErr: true,
Expand All @@ -115,6 +117,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "google",
},
wantErr: true,
Expand Down Expand Up @@ -146,6 +149,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "invalid_provider",
KMSKeyURI: "invalid://key",
},
Expand All @@ -162,6 +166,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "localsecrets",
KMSKeyURI: "base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4=",
},
Expand All @@ -178,6 +183,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "gcpkms",
KMSKeyURI: "gcpkms://projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key",
},
Expand All @@ -194,6 +200,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "awskms",
KMSKeyURI: "awskms://arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012?region=us-east-1",
},
Expand All @@ -210,6 +217,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "azurekeyvault",
KMSKeyURI: "azurekeyvault://myvault.vault.azure.net/keys/mykey",
},
Expand All @@ -226,6 +234,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
KMSProvider: "hashivault",
KMSKeyURI: "hashivault://mykey",
},
Expand All @@ -242,6 +251,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
},
wantErr: false,
},
Expand All @@ -256,6 +266,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 1 * time.Second,
ServerWriteTimeout: 1 * time.Second,
ServerIdleTimeout: 1 * time.Second,
MaxRequestBodySize: 1048576,
},
wantErr: false,
},
Expand All @@ -270,6 +281,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 300 * time.Second,
ServerWriteTimeout: 300 * time.Second,
ServerIdleTimeout: 300 * time.Second,
MaxRequestBodySize: 1048576,
},
wantErr: false,
},
Expand All @@ -284,6 +296,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 0 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
},
wantErr: true,
},
Expand All @@ -298,6 +311,7 @@ func TestConfig_Validate(t *testing.T) {
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 301 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 1048576,
},
wantErr: true,
},
Expand All @@ -315,6 +329,21 @@ func TestConfig_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "invalid max request body size - zero",
cfg: &Config{
DBDriver: "postgres",
DBConnectionString: "postgres://localhost",
ServerPort: 8080,
MetricsPort: 8081,
LogLevel: "info",
ServerReadTimeout: 15 * time.Second,
ServerWriteTimeout: 15 * time.Second,
ServerIdleTimeout: 60 * time.Second,
MaxRequestBodySize: 0,
},
wantErr: true,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -363,6 +392,16 @@ func TestLoad(t *testing.T) {
assert.Equal(t, 15*time.Second, cfg.ServerReadTimeout)
assert.Equal(t, 15*time.Second, cfg.ServerWriteTimeout)
assert.Equal(t, 60*time.Second, cfg.ServerIdleTimeout)
assert.Equal(t, int64(1048576), cfg.MaxRequestBodySize)
},
},
{
name: "load custom body size limit",
envVars: map[string]string{
"MAX_REQUEST_BODY_SIZE": "2097152",
},
validate: func(t *testing.T, cfg *Config) {
assert.Equal(t, int64(2097152), cfg.MaxRequestBodySize)
},
},
{
Expand Down
33 changes: 33 additions & 0 deletions internal/http/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package http

import (
"errors"
"io"
"log/slog"
"net/http"
"time"
Expand Down Expand Up @@ -62,3 +64,34 @@ func CustomRecoveryMiddleware(logger *slog.Logger) gin.HandlerFunc {
c.Next()
}
}

// MaxRequestBodySizeMiddleware restricts the request body size.
func MaxRequestBodySizeMiddleware(limit int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.ContentLength > limit {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
return
}
c.Request.Body = &maxBytesBody{
ctx: c,
ReadCloser: http.MaxBytesReader(c.Writer, c.Request.Body, limit),
}
c.Next()
}
}

type maxBytesBody struct {
ctx *gin.Context
io.ReadCloser
}

func (b *maxBytesBody) Read(p []byte) (n int, err error) {
n, err = b.ReadCloser.Read(p)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
b.ctx.AbortWithStatus(http.StatusRequestEntityTooLarge)
}
}
return n, err
}
63 changes: 63 additions & 0 deletions internal/http/middleware_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package http

import (
"bytes"
"encoding/json"
"io"
"log/slog"
Expand Down Expand Up @@ -88,3 +89,65 @@ func TestRequestIDMiddleware_HeaderPresent(t *testing.T) {
require.NoError(t, err, "X-Request-Id should be a valid UUID")
assert.NotEqual(t, uuid.Nil, parsedUUID, "X-Request-Id should not be nil UUID")
}

// TestMaxRequestBodySizeMiddleware tests the request body size limit middleware.
func TestMaxRequestBodySizeMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
limit int64
requestBody string
expectedStatus int
}{
{
name: "within limit",
limit: 10,
requestBody: "small",
expectedStatus: http.StatusOK,
},
{
name: "at limit",
limit: 10,
requestBody: "0123456789",
expectedStatus: http.StatusOK,
},
{
name: "exceeds limit",
limit: 5,
requestBody: "too large",
expectedStatus: http.StatusRequestEntityTooLarge,
},
{
name: "no content length exceeds limit",
limit: 5,
requestBody: "too large",
expectedStatus: http.StatusRequestEntityTooLarge,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(MaxRequestBodySizeMiddleware(tt.limit))
router.POST("/test", func(c *gin.Context) {
// We need to read the body to trigger MaxBytesReader
_, err := io.ReadAll(c.Request.Body)
if err != nil {
return
}
c.Status(http.StatusOK)
})

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(tt.requestBody))
if tt.name == "no content length exceeds limit" {
req.ContentLength = -1
req.Header.Del("Content-Length")
}
router.ServeHTTP(w, req)

assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
1 change: 1 addition & 0 deletions internal/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (s *Server) SetupRouter(

// Apply custom middleware
router.Use(CustomRecoveryMiddleware(s.logger)) // Custom slog panic recovery
router.Use(MaxRequestBodySizeMiddleware(cfg.MaxRequestBodySize))

// Add CORS middleware if enabled
if corsMiddleware := createCORSMiddleware(
Expand Down
Loading
Loading