diff --git a/.env.example b/.env.example index d14731a..d9e76b5 100644 --- a/.env.example +++ b/.env.example @@ -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 + # Request Timeout Configuration # Global request timeout (seconds) REQUEST_TIMEOUT_SECONDS=60 diff --git a/.env.production.example b/.env.production.example index 30cf4b4..e685893 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/gateway/README.md b/gateway/README.md index 47ad390..dcd5595 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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. | diff --git a/gateway/cache.go b/gateway/cache.go index 031d172..1dff8b7 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -46,11 +46,11 @@ 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 } @@ -58,14 +58,14 @@ func CacheMiddleware() gin.HandlerFunc { 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 } diff --git a/gateway/config.go b/gateway/config.go index 9ff0ebe..7894f5f 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -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 +} diff --git a/gateway/config_test.go b/gateway/config_test.go index 0023a35..c996605 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -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) { @@ -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) + } + }) + + 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"]) + } + } + }) + } +} diff --git a/gateway/main.go b/gateway/main.go index 41be12d..c145773 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -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)}) } else { respondError(c, 500, "request_body_read_failed", err) }