fix: limit API request body size (#21474)

This commit is contained in:
buua436 authored and GitHub committed 2026-10-26 19:27:58 +08:00
1 parent 0bc25c03d5
commit 2f99548911
10 files changed
+292 -34

No files matched your search

+3 -5
View File
@@ -326,13 +326,11 @@ If your model is not currently supported but has APIs compatible with those of O
### How do I change the file size limit?
The Go file-manager upload endpoint allows up to 1 GiB per file by default. The dataset document upload path has a separate 128 MiB per-file limit. Check which upload endpoint rejected the file before changing deployment settings.
The Go API limits non-multipart request bodies to 1 MiB by default. Set `MAX_JSON_CONTENT_LENGTH` to change that limit in bytes. Multipart upload requests share a 1 GiB default total-body limit; set `MAX_MULTIPART_CONTENT_LENGTH` to change it in bytes. Upload endpoints can enforce smaller per-file or per-request limits.
To change the file-manager upload limit in a Go Compose deployment, set `MAX_CONTENT_LENGTH` in **docker/.env** to the desired number of bytes. The default 1 GiB value is `1073741824`.
The file-manager per-file limit follows `MAX_MULTIPART_CONTENT_LENGTH` (1 GiB by default). Dataset documents allow 128 MiB per file, and document-info uploads allow 100 MiB per file; increasing the shared multipart limit does not raise those per-file limits.
If you raise the limit above 1 GiB, also increase `client_max_body_size` in **docker/nginx/nginx.conf**. The Go image contains its own copy of this file, so make the change effective by either rebuilding `RAGFLOW_IMAGE` or enabling the `./nginx/nginx.conf:/etc/nginx/nginx.conf` bind mount for the deployed service in **docker/docker-compose.yml**, then recreate that service. Lowering `MAX_CONTENT_LENGTH` below 1 GiB does not require changing the default Nginx limit.
These settings do not raise the dataset document upload path's fixed 128 MiB limit.
If you raise the multipart limit above 1 GiB, also increase `client_max_body_size` in **docker/nginx/nginx.conf**. The Go image contains its own copy of this file, so make the change effective by either rebuilding `RAGFLOW_IMAGE` or enabling the `./nginx/nginx.conf:/etc/nginx/nginx.conf` bind mount for the deployed service in **docker/docker-compose.yml**, then recreate that service.
---
+2
View File
@@ -88,6 +88,8 @@ const (
EnvDocEngine = "DOC_ENGINE"
EnvMaxFileNumPerUser = "MAX_FILE_NUM_PER_USER"
EnvMaxContentLength = "MAX_CONTENT_LENGTH"
EnvMaxJSONContentLength = "MAX_JSON_CONTENT_LENGTH"
EnvMaxMultipartContentLength = "MAX_MULTIPART_CONTENT_LENGTH"
EnvRAGFlowDictPath = "RAGFLOW_DICT_PATH"
EnvDefaultSuperuserEmail = "DEFAULT_SUPERUSER_EMAIL"
EnvDefaultSuperuserNickname = "DEFAULT_SUPERUSER_NICKNAME"
+36
View File
@@ -38,8 +38,44 @@ import (
const (
redactedLogValue = "[REDACTED]"
maxLoggedVectorFloats = 3
// DefaultMaxJSONRequestBodyBytes is the default limit for non-multipart requests.
DefaultMaxJSONRequestBodyBytes int64 = 1 << 20
// DefaultMaxMultipartRequestBodyBytes is the default total limit for multipart uploads.
DefaultMaxMultipartRequestBodyBytes int64 = 1 << 30
// MaxWebhookRequestBodyBytes is the default and maximum request body size for agent webhooks.
MaxWebhookRequestBodyBytes int64 = 10 * 1_000_000
)
// GetMaxJSONRequestBodyBytes returns the configured non-multipart request-body
// limit in bytes, defaulting to 1 MiB.
func GetMaxJSONRequestBodyBytes() int64 {
return getMaxRequestBodyBytes(EnvMaxJSONContentLength, "", DefaultMaxJSONRequestBodyBytes)
}
// GetMaxMultipartRequestBodyBytes returns the configured multipart upload
// request-body limit in bytes, defaulting to 1 GiB. MAX_CONTENT_LENGTH remains
// a fallback for existing deployments when the new variable is unset.
func GetMaxMultipartRequestBodyBytes() int64 {
return getMaxRequestBodyBytes(EnvMaxMultipartContentLength, EnvMaxContentLength, DefaultMaxMultipartRequestBodyBytes)
}
func getMaxRequestBodyBytes(envName, fallbackEnvName string, defaultBytes int64) int64 {
raw := strings.TrimSpace(GetEnv(envName))
if raw == "" && fallbackEnvName != "" {
raw = strings.TrimSpace(GetEnv(fallbackEnvName))
}
if raw == "" {
return defaultBytes
}
limit, err := strconv.ParseInt(raw, 10, 64)
if err != nil || limit <= 0 {
return defaultBytes
}
return limit
}
var ssrfHttpClient *http.Client
var schemeSafeHttpClient *http.Client
+17 -16
View File
@@ -60,13 +60,8 @@ const (
bytesPerKB int64 = 1000
bytesPerMB int64 = bytesPerKB * 1000
// webhookBodyMaxBytes is the hard ceiling above which a configured
// max_body_size is rejected at config-validation time. Stated
// in decimal MB to match the kb/mb unit base above; 10 MB
// decimal = 10 000 000 bytes. The python reference
// (agent_api.py:1652) uses 10 * 1024 * 1024 = 10 485 760 bytes,
// so this is ~486 KB stricter than python.
webhookBodyMaxBytes int64 = 10 * bytesPerMB
// webhookBodyMaxBytes is the default and hard maximum request body size.
webhookBodyMaxBytes int64 = common.MaxWebhookRequestBodyBytes
// webhookRateLimitTimeout is the redis call timeout used for the
// strict token-bucket lookup. Short on purpose — a security gate
@@ -149,12 +144,11 @@ func validateMaxBodySize(c *gin.Context, cfg map[string]any) error {
return nil
}
// parseMaxBodySize returns the byte limit configured by
// `max_body_size` (with the 10 MB cap enforced) or 0 when no limit
// is configured. The handler uses this to wrap c.Request.Body in
// http.MaxBytesReader so the actual stream read is bounded — the
// Content-Length header check alone is insufficient because a client
// can advertise a small Content-Length and stream more.
// parseMaxBodySize returns the configured body limit, defaulting to 10 MB
// when `max_body_size` is absent. Configured values cannot exceed that cap.
// The handler wraps c.Request.Body in http.MaxBytesReader so the actual stream
// read is bounded — the Content-Length header check alone is insufficient
// because a client can advertise a small Content-Length and stream more.
//
// Unit base: 1 kb = 1 000 B, 1 mb = 1 000 000 B (SI decimal). Per
// user request; note this DIVERGES from the python reference
@@ -166,11 +160,18 @@ func validateMaxBodySize(c *gin.Context, cfg map[string]any) error {
// the 10 MB cap. We check the parsed number against the cap before
// multiplying.
func parseMaxBodySize(cfg map[string]any) (int64, error) {
raw, ok := cfg["max_body_size"].(string)
if !ok || raw == "" {
return 0, nil
rawValue, exists := cfg["max_body_size"]
if !exists || rawValue == nil {
return webhookBodyMaxBytes, nil
}
raw, ok := rawValue.(string)
if !ok {
return 0, fmt.Errorf("invalid max_body_size format")
}
sizeStr := strings.ToLower(strings.TrimSpace(raw))
if sizeStr == "" {
return webhookBodyMaxBytes, nil
}
var limit int64
switch {
case strings.HasSuffix(sizeStr, "kb"):
@@ -357,6 +357,22 @@ func TestValidateMaxBodySize_OverflowGuard(t *testing.T) {
}
}
func TestParseMaxBodySizeDefaultsTo10MB(t *testing.T) {
for _, cfg := range []map[string]any{
nil,
{},
{"max_body_size": ""},
} {
got, err := parseMaxBodySize(cfg)
if err != nil {
t.Fatalf("parseMaxBodySize(%v): %v", cfg, err)
}
if got != common.MaxWebhookRequestBodyBytes {
t.Errorf("parseMaxBodySize(%v) = %d, want %d", cfg, got, common.MaxWebhookRequestBodyBytes)
}
}
}
// TestParseMaxBodySize_DecimalUnits documents the per-user-request
// SI-decimal unit base: 1 kb = 1 000 B, 1 mb = 1 000 000 B.
// (The python reference uses 1 024 / 1 048 576.)
+64
View File
@@ -0,0 +1,64 @@
// Package router owns the API request-size policy.
package router
import (
"mime"
"net/http"
"github.com/gin-gonic/gin"
"ragflow/internal/common"
)
const agentWebhookRoute = "/api/v1/agents/:canvas_id/webhook"
// requestBodyLimitMiddleware applies configurable limits to API request bodies.
// Multipart upload routes use a shared limit; handlers may enforce lower
// endpoint-specific limits. Public Agent webhooks retain their 10 MB ceiling.
func requestBodyLimitMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body == nil || c.Request.Body == http.NoBody {
c.Next()
return
}
limit := common.GetMaxJSONRequestBodyBytes()
fullPath := c.FullPath()
switch {
case fullPath == agentWebhookRoute:
limit = common.MaxWebhookRequestBodyBytes
case isMultipartUploadRequest(c):
limit = common.GetMaxMultipartRequestBodyBytes()
}
if c.Request.ContentLength > limit {
common.ResponseWithHttpCodeData(c, http.StatusRequestEntityTooLarge, common.CodeBadRequest, nil, "request body too large")
c.Abort()
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
c.Next()
}
}
func isMultipartUploadRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost || !isMultipartFormData(c.Request.Header.Get("Content-Type")) {
return false
}
switch c.FullPath() {
case "/api/v1/files",
"/api/v1/agents/:canvas_id/upload",
"/api/v1/chat/audio/transcription",
"/api/v1/documents/upload",
"/api/v1/datasets/:dataset_id/documents":
return true
default:
return false
}
}
func isMultipartFormData(contentType string) bool {
mediaType, _, err := mime.ParseMediaType(contentType)
return err == nil && mediaType == "multipart/form-data"
}
+142
View File
@@ -0,0 +1,142 @@
package router
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"ragflow/internal/common"
)
func TestRequestBodyLimitMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("MAX_JSON_CONTENT_LENGTH", "")
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "")
t.Setenv("MAX_CONTENT_LENGTH", "")
engine := gin.New()
engine.Use(requestBodyLimitMiddleware())
readBody := func(c *gin.Context) {
_, err := io.Copy(io.Discard, c.Request.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.Status(http.StatusRequestEntityTooLarge)
return
}
c.Status(http.StatusBadRequest)
return
}
c.Status(http.StatusNoContent)
}
engine.POST("/api/v1/test", readBody)
engine.POST("/api/v1/files", readBody)
engine.DELETE("/api/v1/files", readBody)
engine.POST("/api/v1/agents/:canvas_id/upload", readBody)
engine.POST("/api/v1/chat/audio/transcription", readBody)
engine.POST("/api/v1/documents/upload", readBody)
engine.POST("/api/v1/datasets/:dataset_id/documents", readBody)
engine.POST(agentWebhookRoute, readBody)
t.Run("rejects oversized default request", func(t *testing.T) {
body := bytes.Repeat([]byte("x"), int(common.DefaultMaxJSONRequestBodyBytes+1))
req := httptest.NewRequest(http.MethodPost, "/api/v1/test", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
engine.ServeHTTP(resp, req)
if resp.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d, want %d", resp.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("bounds chunked request body", func(t *testing.T) {
body := bytes.Repeat([]byte("x"), int(common.DefaultMaxJSONRequestBodyBytes+1))
req := httptest.NewRequest(http.MethodPost, "/api/v1/test", bytes.NewReader(body))
req.ContentLength = -1
req.TransferEncoding = []string{"chunked"}
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
engine.ServeHTTP(resp, req)
if resp.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d, want %d", resp.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("allows webhook body up to 10 MB", func(t *testing.T) {
body := bytes.Repeat([]byte("x"), int(common.DefaultMaxJSONRequestBodyBytes+1))
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/webhook", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
engine.ServeHTTP(resp, req)
if resp.Code != http.StatusNoContent {
t.Fatalf("status=%d, want %d", resp.Code, http.StatusNoContent)
}
})
t.Run("rejects webhook body above 10 MB", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/webhook", bytes.NewReader(nil))
req.ContentLength = common.MaxWebhookRequestBodyBytes + 1
resp := httptest.NewRecorder()
engine.ServeHTTP(resp, req)
if resp.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d, want %d", resp.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("does not exempt non-upload methods on file routes", func(t *testing.T) {
body := bytes.Repeat([]byte("x"), int(common.DefaultMaxJSONRequestBodyBytes+1))
req := httptest.NewRequest(http.MethodDelete, "/api/v1/files", bytes.NewReader(body))
req.Header.Set("Content-Type", "multipart/form-data; boundary=test")
resp := httptest.NewRecorder()
engine.ServeHTTP(resp, req)
if resp.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d, want %d", resp.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("uses configured JSON request-body limit", func(t *testing.T) {
t.Setenv("MAX_JSON_CONTENT_LENGTH", "16")
body := bytes.Repeat([]byte("x"), 17)
request := httptest.NewRequest(http.MethodPost, "/api/v1/test", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d, want %d", response.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("uses configured multipart limit for every upload route", func(t *testing.T) {
t.Setenv("MAX_JSON_CONTENT_LENGTH", "8")
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "16")
paths := []string{
"/api/v1/files",
"/api/v1/agents/a1/upload",
"/api/v1/chat/audio/transcription",
"/api/v1/documents/upload",
"/api/v1/datasets/d1/documents",
}
for _, path := range paths {
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(bytes.Repeat([]byte("x"), 12)))
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Errorf("POST %s status=%d, want %d for body above JSON limit", path, response.Code, http.StatusNoContent)
}
request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(bytes.Repeat([]byte("x"), 17)))
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
response = httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Errorf("POST %s status=%d, want %d for body above multipart limit", path, response.Code, http.StatusRequestEntityTooLarge)
}
}
})
}
+1
View File
@@ -136,6 +136,7 @@ func NewRouter(
// Setup setup routes
func (r *Router) Setup(engine *gin.Engine) {
engine.Use(requestBodyLimitMiddleware())
SetupEERouter(engine)
// Mark all responses from Go with a header for debugging.
+9 -2
View File
@@ -42,20 +42,27 @@ func testFileService() *FileService {
}
func TestDeploymentUploadMaxBytes(t *testing.T) {
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "")
t.Setenv("MAX_CONTENT_LENGTH", "")
if got := DeploymentUploadMaxBytes(); got != defaultDeploymentUploadMaxBytes {
t.Fatalf("default limit = %d, want %d", got, defaultDeploymentUploadMaxBytes)
}
t.Setenv("MAX_CONTENT_LENGTH", "12345")
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "12345")
if got := DeploymentUploadMaxBytes(); got != 12345 {
t.Fatalf("configured limit = %d, want 12345", got)
}
t.Setenv("MAX_CONTENT_LENGTH", "invalid")
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "invalid")
if got := DeploymentUploadMaxBytes(); got != defaultDeploymentUploadMaxBytes {
t.Fatalf("invalid limit = %d, want default %d", got, defaultDeploymentUploadMaxBytes)
}
t.Setenv("MAX_MULTIPART_CONTENT_LENGTH", "")
t.Setenv("MAX_CONTENT_LENGTH", "54321")
if got := DeploymentUploadMaxBytes(); got != 54321 {
t.Fatalf("legacy configured limit = %d, want 54321", got)
}
}
func TestReadDeploymentUploadDataLimit(t *testing.T) {
+2 -11
View File
@@ -13,23 +13,14 @@ import (
"ragflow/internal/permission"
"ragflow/internal/storage"
"ragflow/internal/utility"
"strconv"
"strings"
"time"
)
const defaultDeploymentUploadMaxBytes int64 = 1 << 30
const defaultDeploymentUploadMaxBytes int64 = common.DefaultMaxMultipartRequestBodyBytes
func DeploymentUploadMaxBytes() int64 {
raw := common.GetEnv(common.EnvMaxContentLength)
if raw == "" {
return defaultDeploymentUploadMaxBytes
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return defaultDeploymentUploadMaxBytes
}
return n
return common.GetMaxMultipartRequestBodyBytes()
}
// UploadFile uploads files to a folder