Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ RATE_LIMIT_VERIFIED_RPM=120
# Cleanup interval for stale buckets (seconds)
RATE_LIMIT_CLEANUP_INTERVAL=300

# Request Body Size Configuration
# Maximum request body size in MB (default: 10)
MAX_REQUEST_BODY_MB=10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document MAX_REQUEST_BODY_MB in config references

This introduces a new operator-facing gateway setting, but only .env.example mentions it; the main README configuration table, gateway/README.md optional-variable table, and .env.production.example still omit it. In production/setup contexts users will not discover the new limit knob or know its default/unit, so please keep those config references aligned with this new env var.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


# Request Timeout Configuration
# Global request timeout (seconds)
REQUEST_TIMEOUT_SECONDS=60
Expand Down
2 changes: 2 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ SIGNATURE_EXPIRY_SECONDS=300
SIGNATURE_CLOCK_SKEW_SECONDS=60
RECEIPT_STORE=redis
CACHE_ENABLED=false
# Maximum request body size in MB (default: 10, max: 100).
# MAX_REQUEST_BODY_MB=10
REQUEST_TIMEOUT_SECONDS=60
AI_REQUEST_TIMEOUT_SECONDS=30
# VERIFIER_TIMEOUT_SECONDS at the 2s default fails against Render free-tier
Expand Down
1 change: 1 addition & 0 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Common optional variables:
| `RECEIPT_TTL` | `86400` | Receipt TTL in seconds. |
| `CACHE_ENABLED` | `false` | Optional response cache. |
| `CACHE_TTL_SECONDS` | `3600` | Response cache TTL. |
| `MAX_REQUEST_BODY_MB` | `10` | Maximum request body size in MB (clamped to 100). |
| `METRICS_ENABLED` | enabled unless set to `false` | Exposes Prometheus metrics. |
| `METRICS_PATH` | `/metrics` | Gateway metrics endpoint path. Missing leading slash is normalized. |
| `RATE_LIMIT_ENABLED` | disabled unless set to `true` or `1` | Enables token bucket middleware. |
Expand Down
10 changes: 5 additions & 5 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,26 @@ func CacheMiddleware() gin.HandlerFunc {

// Read request body to generate cache key
// Check Content-Length first to reject oversized requests immediately
const maxBodySize = 10 * 1024 * 1024
maxSize := getMaxBodySize()
// ContentLength == -1 means unknown (chunked encoding or no header), proceed to MaxBytesReader
if c.Request.ContentLength > maxBodySize {
if c.Request.ContentLength > maxSize {
c.Header("Connection", "close")
c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"})
c.JSON(413, gin.H{"error": "Payload too large", "max_size": fmt.Sprintf("%dMB", maxSize/1024/1024)})
c.Abort()
return
}

var requestBody []byte
var err error
if c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize))
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
requestBody, err = io.ReadAll(c.Request.Body)
if err != nil {
// If body too large, MaxBytesReader returns error
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
c.Header("Connection", "close")
c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"})
c.JSON(413, gin.H{"error": "Payload too large", "max_size": fmt.Sprintf("%dMB", maxSize/1024/1024)})
c.Abort()
return
}
Expand Down
19 changes: 19 additions & 0 deletions gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,22 @@ func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_TI
func getHealthCheckTimeout() time.Duration {
return getPositiveTimeout("HEALTH_CHECK_TIMEOUT_SECONDS", 2)
}

const maxBodySizeMB = 10
const maxBodySizeMBMax = 100 // prevents memory exhaustion from io.ReadAll buffering

// getMaxBodySize returns the maximum request body size in bytes, configured via
// the MAX_REQUEST_BODY_MB environment variable. Defaults to 10MB. Clamped to
// maxBodySizeMBMax to prevent int64 overflow when converting to bytes.
func getMaxBodySize() int64 {
mb := getEnvAsInt("MAX_REQUEST_BODY_MB", maxBodySizeMB)
switch {
case mb <= 0:
log.Printf("Warning: MAX_REQUEST_BODY_MB must be positive, using default %d", maxBodySizeMB)
mb = maxBodySizeMB
case mb > maxBodySizeMBMax:
log.Printf("Warning: MAX_REQUEST_BODY_MB (%d) exceeds maximum %d, clamping", mb, maxBodySizeMBMax)
mb = maxBodySizeMBMax
}
return int64(mb) * 1024 * 1024
Comment thread
AnkanMisra marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
144 changes: 144 additions & 0 deletions gateway/config_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"

"github.com/gin-gonic/gin"
)

func TestValidateConfig_MissingRequiredEnv(t *testing.T) {
Expand Down Expand Up @@ -474,3 +483,138 @@ func TestGetReceiptTTL(t *testing.T) {
func stringPtr(value string) *string {
return &value
}

func TestGetMaxBodySize(t *testing.T) {
t.Run("default when unset", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "")
if got := getMaxBodySize(); got != 10*1024*1024 {
t.Fatalf("expected 10MB default, got %d", got)
}
})

Comment on lines +487 to +494

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add route-level regression coverage for configured 413 responses

These added tests only validate getMaxBodySize() parsing, but they never exercise handleSummarize or CacheMiddleware with MAX_REQUEST_BODY_MB set to a smaller value and an oversized request body

That leaves both the cached and uncached 413 responses unprotected, so a future drift in the actual rejection path or max_size payload would still pass CI even though this PR changes a public error contract there

t.Run("custom value", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "25")
if got := getMaxBodySize(); got != 25*1024*1024 {
t.Fatalf("expected 25MB, got %d", got)
}
})

t.Run("zero falls back to default", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "0")
if got := getMaxBodySize(); got != 10*1024*1024 {
t.Fatalf("expected 10MB fallback for zero, got %d", got)
}
})

t.Run("negative falls back to default", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "-5")
if got := getMaxBodySize(); got != 10*1024*1024 {
t.Fatalf("expected 10MB fallback for negative, got %d", got)
}
})

t.Run("overflow clamped to max", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "99999")
if got := getMaxBodySize(); got != 100*1024*1024 {
t.Fatalf("expected 100MB clamped max, got %d", got)
}
})

t.Run("non-numeric falls back to default", func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", "not-a-number")
if got := getMaxBodySize(); got != 10*1024*1024 {
t.Fatalf("expected 10MB fallback for non-numeric, got %d", got)
}
})
}

func TestRequestBodyLimit_EnforcedAtRouteLevel(t *testing.T) {
tests := []struct {
name string
envMB string
bodySize int
wantStatus int
wantMax string
}{
{
name: "body below limit succeeds",
envMB: "10",
bodySize: 1024,
wantStatus: 200,
wantMax: "",
},
{
name: "body just under limit succeeds",
envMB: "1",
bodySize: 1024*1024 - 1,
wantStatus: 200,
wantMax: "",
},
{
name: "body at limit succeeds",
envMB: "1",
bodySize: 1024 * 1024,
wantStatus: 200,
wantMax: "",
},
{
name: "body exceeds limit returns 413",
envMB: "1",
bodySize: 2 * 1024 * 1024,
wantStatus: 413,
wantMax: "1MB",
},
{
name: "body way over limit returns 413",
envMB: "2",
bodySize: 10 * 1024 * 1024,
wantStatus: 413,
wantMax: "2MB",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("MAX_REQUEST_BODY_MB", tt.envMB)
gin.SetMode(gin.TestMode)
r := gin.Default()
r.POST("/test", func(c *gin.Context) {
maxSize := getMaxBodySize()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
_, err := io.ReadAll(c.Request.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
c.JSON(413, gin.H{
"error": "Payload too large",
"max_size": fmt.Sprintf("%dMB", maxSize/1024/1024),
})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
})

body := bytes.Repeat([]byte("A"), tt.bodySize)
req, _ := http.NewRequest("POST", "/test", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

if w.Code != tt.wantStatus {
t.Fatalf("expected status %d, got %d", tt.wantStatus, w.Code)
}

if tt.wantMax != "" {
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["max_size"] != tt.wantMax {
t.Errorf("expected max_size %q, got %v", tt.wantMax, resp["max_size"])
}
}
})
}
}
6 changes: 3 additions & 3 deletions gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,13 +463,13 @@ func handleSummarize(c *gin.Context) {
// Read body if not already available
if requestBody == nil {
// Read body with limit (only if middleware didn't process it)
const maxBodySize = 10 * 1024 * 1024
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize))
maxSize := getMaxBodySize()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
requestBody, err = io.ReadAll(c.Request.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"})
c.JSON(413, gin.H{"error": "Payload too large", "max_size": fmt.Sprintf("%dMB", maxSize/1024/1024)})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
respondError(c, 500, "request_body_read_failed", err)
}
Expand Down
Loading