This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 7f2953e96 feat: add max_req_body_size to bound client request body in 
forward-auth and ai-proxy (#13466)
7f2953e96 is described below

commit 7f2953e9667a8fb7231081a4342197d94b66e111
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Wed Jun 10 11:13:27 2026 +0800

    feat: add max_req_body_size to bound client request body in forward-auth 
and ai-proxy (#13466)
---
 apisix/core/request.lua                  | 10 ++---
 apisix/plugins/ai-proxy-multi.lua        | 19 +++++---
 apisix/plugins/ai-proxy.lua              | 12 +++--
 apisix/plugins/ai-proxy/base.lua         | 13 +++++-
 apisix/plugins/ai-proxy/schema.lua       | 16 +++++++
 apisix/plugins/forward-auth.lua          | 15 ++++++-
 docs/en/latest/plugins/ai-proxy-multi.md |  1 +
 docs/en/latest/plugins/ai-proxy.md       |  1 +
 docs/en/latest/plugins/forward-auth.md   |  1 +
 t/plugin/ai-proxy-multi.balancer.t       | 11 +++--
 t/plugin/ai-proxy-stream-limits.t        | 53 ++++++++++++++++++++++
 t/plugin/forward-auth.t                  | 77 ++++++++++++++++++++++++++++++++
 12 files changed, 207 insertions(+), 22 deletions(-)

diff --git a/apisix/core/request.lua b/apisix/core/request.lua
index 9989d955e..65c01c5ea 100644
--- a/apisix/core/request.lua
+++ b/apisix/core/request.lua
@@ -340,7 +340,7 @@ end
 -- When content_type is given (e.g. CONTENT_TYPE_JSON), it takes precedence 
over
 -- the request Content-Type header. A cache hit is only reused when
 -- ctx._request_body_type matches, preventing type confusion between callers.
-local function get_request_body_table(ctx, content_type)
+local function get_request_body_table(ctx, content_type, max_size)
     if not ctx then
         ctx = ngx.ctx.api_ctx
     end
@@ -358,7 +358,7 @@ local function get_request_body_table(ctx, content_type)
     local result, err, detected_type
 
     if core_str.find(ct, CONTENT_TYPE_JSON) then
-        local body, body_err = _M.get_body()
+        local body, body_err = _M.get_body(max_size, ctx)
         if not body then
             return nil, "could not get body: " .. (body_err or "request body 
is empty")
         end
@@ -376,7 +376,7 @@ local function get_request_body_table(ctx, content_type)
         detected_type = CONTENT_TYPE_FORM_URLENCODED
 
     elseif core_str.find(ct, CONTENT_TYPE_MULTIPART_FORM) then
-        local body, body_err = _M.get_body()
+        local body, body_err = _M.get_body(max_size, ctx)
         if not body then
             return nil, "could not get body: " .. (body_err or "request body 
is empty")
         end
@@ -403,9 +403,9 @@ end
 _M.get_request_body_table = get_request_body_table
 
 
-function _M.get_json_request_body_table()
+function _M.get_json_request_body_table(max_size)
     local ctx = ngx.ctx.api_ctx
-    local body_tab, err = get_request_body_table(ctx, CONTENT_TYPE_JSON)
+    local body_tab, err = get_request_body_table(ctx, CONTENT_TYPE_JSON, 
max_size)
     if not body_tab then
         return nil, { message = err }
     end
diff --git a/apisix/plugins/ai-proxy-multi.lua 
b/apisix/plugins/ai-proxy-multi.lua
index feffa77c5..0d23ad096 100644
--- a/apisix/plugins/ai-proxy-multi.lua
+++ b/apisix/plugins/ai-proxy-multi.lua
@@ -501,6 +501,15 @@ local function pick_ai_instance(ctx, conf, ups_tab)
 end
 
 function _M.access(conf, ctx)
+    -- Detect the client protocol and read the body first. 
get_json_request_body_table
+    -- reads and size-checks the body exactly once (bounded by 
max_req_body_size,
+    -- rejecting via Content-Length before buffering), so oversized requests 
are
+    -- rejected before any balancer / DNS / health-check work below.
+    local err, code = base.detect_request_type(ctx, conf.max_req_body_size)
+    if err then
+        return code or 400, err
+    end
+
     local ups_tab = {}
     local algo = core.table.try_read_attr(conf, "balancer", "algorithm")
     if algo == "chash" then
@@ -510,18 +519,14 @@ function _M.access(conf, ctx)
         ups_tab["hash_on"] = hash_on
     end
 
-    local name, ai_instance, err = pick_ai_instance(ctx, conf, ups_tab)
-    if err then
-        return 503, err
+    local name, ai_instance, perr = pick_ai_instance(ctx, conf, ups_tab)
+    if perr then
+        return 503, perr
     end
     ctx.picked_ai_instance_name = name
     ctx.picked_ai_instance = ai_instance
     ctx.balancer_ip = name
     ctx.bypass_nginx_upstream = true
-    local err = base.detect_request_type(ctx)
-    if err then
-        return 400, err
-    end
 end
 
 
diff --git a/apisix/plugins/ai-proxy.lua b/apisix/plugins/ai-proxy.lua
index 8922c1633..09afc7c48 100644
--- a/apisix/plugins/ai-proxy.lua
+++ b/apisix/plugins/ai-proxy.lua
@@ -54,14 +54,18 @@ end
 
 
 function _M.access(conf, ctx)
+    -- Detect the client protocol and read the body first. 
get_json_request_body_table
+    -- reads and size-checks the body exactly once (bounded by 
max_req_body_size,
+    -- rejecting via Content-Length before buffering), so oversized requests 
are
+    -- rejected up front without redundant Content-Length handling.
+    local err, code = base.detect_request_type(ctx, conf.max_req_body_size)
+    if err then
+        return code or 400, err
+    end
     ctx.picked_ai_instance_name = "ai-proxy-" .. conf.provider
     ctx.picked_ai_instance = conf
     ctx.balancer_ip = ctx.picked_ai_instance_name
     ctx.bypass_nginx_upstream = true
-    local err = base.detect_request_type(ctx)
-    if err then
-        return 400, err
-    end
 end
 
 
diff --git a/apisix/plugins/ai-proxy/base.lua b/apisix/plugins/ai-proxy/base.lua
index 8ce5ef9de..d2b6e67a9 100644
--- a/apisix/plugins/ai-proxy/base.lua
+++ b/apisix/plugins/ai-proxy/base.lua
@@ -66,15 +66,24 @@ end
 -- Detect client protocol and stream mode early in access phase,
 -- so that plugins with lower priority can use ctx.ai_client_protocol
 -- and ctx.var.request_type before before_proxy runs.
-function _M.detect_request_type(ctx)
+function _M.detect_request_type(ctx, max_req_body_size)
     local ct = core.request.header(ctx, "Content-Type") or "application/json"
     if not core.string.has_prefix(ct, "application/json") then
         return "unsupported content-type: " .. ct
             .. ", only application/json is supported"
     end
 
-    local body, err = core.request.get_json_request_body_table()
+    local body, err = 
core.request.get_json_request_body_table(max_req_body_size)
     if not body then
+        -- get_json_request_body_table wraps the underlying error as 
{message=...}.
+        -- An oversized body must surface as 413; all other read/parse failures
+        -- stay 400 (caller default).
+        local msg = type(err) == "table" and err.message or err
+        if type(msg) == "string"
+           and core.string.find(msg, "greater than the maximum size", 1, true) 
then
+            core.log.error("failed to read request body: ", msg)
+            return err, 413
+        end
         return err
     end
 
diff --git a/apisix/plugins/ai-proxy/schema.lua 
b/apisix/plugins/ai-proxy/schema.lua
index 4cec19bb1..5ffd6ef85 100644
--- a/apisix/plugins/ai-proxy/schema.lua
+++ b/apisix/plugins/ai-proxy/schema.lua
@@ -243,6 +243,14 @@ _M.ai_proxy_schema = {
             default = 30000,
             description = "timeout in milliseconds",
         },
+        max_req_body_size = {
+            type = "integer",
+            minimum = 1,
+            default = 67108864,
+            description = "maximum request body size in bytes the plugin reads 
"
+                       .. "into memory; larger requests are rejected with 413. 
"
+                       .. "Prevents unbounded memory buffering of large 
bodies.",
+        },
         max_stream_duration_ms = {
             type = "integer",
             minimum = 1,
@@ -360,6 +368,14 @@ _M.ai_proxy_multi_schema = {
             default = 30000,
             description = "timeout in milliseconds",
         },
+        max_req_body_size = {
+            type = "integer",
+            minimum = 1,
+            default = 67108864,
+            description = "maximum request body size in bytes the plugin reads 
"
+                       .. "into memory; larger requests are rejected with 413. 
"
+                       .. "Prevents unbounded memory buffering of large 
bodies.",
+        },
         max_stream_duration_ms = {
             type = "integer",
             minimum = 1,
diff --git a/apisix/plugins/forward-auth.lua b/apisix/plugins/forward-auth.lua
index 2f25c7ed9..3f2be5de4 100644
--- a/apisix/plugins/forward-auth.lua
+++ b/apisix/plugins/forward-auth.lua
@@ -38,6 +38,14 @@ local schema = {
             enum = {"GET", "POST"},
             description = "the method for client to request the authorization 
service"
         },
+        max_req_body_size = {
+            type = "integer",
+            minimum = 1,
+            default = 67108864,
+            description = "maximum request body size in bytes buffered and "
+                        .. "forwarded to the authorization service when "
+                        .. "request_method is POST"
+        },
         request_headers = {
             type = "array",
             default = {},
@@ -152,7 +160,12 @@ function _M.access(conf, ctx)
     }
 
     if params.method == "POST" then
-        params.body = core.request.get_body()
+        local body, err = core.request.get_body(conf.max_req_body_size)
+        if err then
+            core.log.error("failed to read request body: ", err)
+            return 413
+        end
+        params.body = body
     end
 
     if conf.keepalive then
diff --git a/docs/en/latest/plugins/ai-proxy-multi.md 
b/docs/en/latest/plugins/ai-proxy-multi.md
index fe25e0d77..43c635135 100644
--- a/docs/en/latest/plugins/ai-proxy-multi.md
+++ b/docs/en/latest/plugins/ai-proxy-multi.md
@@ -123,6 +123,7 @@ When an instance's `provider` is set to `bedrock`, the 
Plugin expects requests i
 | instances.checks.active.unhealthy.http_failures | integer      | False    | 
5                               | between 1 and 254 inclusive | Number of HTTP 
failures to define an unhealthy node. |
 | instances.checks.active.unhealthy.timeout     | integer        | False    | 
3                               | between 1 and 254 inclusive | Number of probe 
timeouts to define an unhealthy node. |
 | timeout                             | integer        | False    | 30000      
                     | greater than or equal to 1 | Request timeout in 
milliseconds when requesting the LLM service. Applied per socket operation 
(connect / send / read block); does not cap the total duration of a streaming 
response. |
+| max_req_body_size                   | integer        | False    | 67108864   
                     | greater than or equal to 1 | Maximum request body size 
in bytes that the plugin reads into memory. Requests whose body exceeds this 
limit are rejected with `413`. Prevents unbounded memory buffering of large 
request bodies. |
 | max_stream_duration_ms              | integer        | False    |            
                     | greater than or equal to 1 | Maximum wall-clock duration 
(in milliseconds) for a streaming AI response. If the upstream keeps sending 
data past this deadline, the gateway closes the connection. Unset means no cap. 
Use this to protect the gateway from upstream bugs that produce tokens 
indefinitely. When the limit is hit mid-stream, the downstream SSE stream is 
truncated (no protocol-speci [...]
 | max_response_bytes                  | integer        | False    |            
                     | greater than or equal to 1 | Maximum total bytes read 
from the upstream for a single AI response (streaming or non-streaming). If 
exceeded, the gateway closes the connection. For non-streaming responses with 
`Content-Length`, the check is performed before reading the body; for chunked 
(no-`Content-Length`) non-streaming responses and for streaming responses, the 
cap is enforced increment [...]
 | keepalive                           | boolean        | False    | true       
                     |              | If true, keep the connection alive when 
requesting the LLM service. |
diff --git a/docs/en/latest/plugins/ai-proxy.md 
b/docs/en/latest/plugins/ai-proxy.md
index cb7347a8a..802b49593 100644
--- a/docs/en/latest/plugins/ai-proxy.md
+++ b/docs/en/latest/plugins/ai-proxy.md
@@ -94,6 +94,7 @@ When `provider` is set to `bedrock`, the Plugin expects 
requests in the [Bedrock
 | logging.summaries | boolean | False | false |                                
          | If true, logs request LLM model, duration, request, and response 
tokens. |
 | logging.payloads  | boolean | False | false |                                
          | If true, logs request and response payload. |
 | timeout        | integer | False    | 30000    | 1 - 600000                  
             | Request timeout in milliseconds when requesting the LLM service. 
|
+| max_req_body_size | integer | False | 67108864 | >= 1 | Maximum request body 
size in bytes that the plugin reads into memory. Requests whose body exceeds 
this limit are rejected with `413`. Prevents unbounded memory buffering of 
large request bodies. |
 | keepalive      | boolean | False    | true   |                               
           | If true, keeps the connection alive when requesting the LLM 
service. |
 | keepalive_timeout | integer | False | 60000  | ≥ 1000                        
           | Keepalive timeout in milliseconds when connecting to the LLM 
service. |
 | keepalive_pool | integer | False    | 30       | ≥ 1                         
             | Keepalive pool size for the LLM service connection. |
diff --git a/docs/en/latest/plugins/forward-auth.md 
b/docs/en/latest/plugins/forward-auth.md
index 18f0a661c..13f7e9ef3 100644
--- a/docs/en/latest/plugins/forward-auth.md
+++ b/docs/en/latest/plugins/forward-auth.md
@@ -52,6 +52,7 @@ The `forward-auth` Plugin supports the integration with an 
external authorizatio
 | uri               | string        | True     |         |                     
      | URI of the external authorization service.                              
                                                                                
                                                                                
                                                    |
 | ssl_verify        | boolean       | False    | true    |                     
      | If true, verify the authorization service's SSL certificate.            
                                                                                
                                                                                
                                                    |
 | request_method    | string        | False    | GET     | `GET` or `POST`     
      | HTTP method APISIX uses to send requests to the external authorization 
service. When set to `POST`, APISIX will send POST requests along with the 
request body to the external authorization service. If the authorization 
decision depends on request parameters from a POST body, it is recommended to 
extract the necessary fields using `$post_arg.*` and pass them via the 
`extra_headers` field instead. |
+| max_req_body_size | integer       | False    | 67108864 | >= 1               
      | Maximum request body size in bytes buffered and forwarded to the 
external authorization service when `request_method` is `POST`. Requests whose 
body exceeds this limit are rejected with `413`. Prevents unbounded memory 
buffering of large request bodies.                                  |
 | request_headers   | array         | False    |         |                     
      | Client request headers that should be forwarded to the external 
authorization service. If not configured, only headers added by APISIX are 
forwarded, such as `X-Forwarded-*`.                                             
                                                                 |
 | upstream_headers  | array         | False    |         |                     
      | External authorization service response headers that should be 
forwarded to the Upstream service. If not configured, no headers are forwarded 
to the Upstream service.                                                        
                                                              |
 | client_headers    | array         | False    |         |                     
      | External authorization service response headers that should be 
forwarded to the client when authentication fails. If not configured, no 
headers are forwarded to the client.                                            
                                                                    |
diff --git a/t/plugin/ai-proxy-multi.balancer.t 
b/t/plugin/ai-proxy-multi.balancer.t
index ee22277e3..aad79e8fb 100644
--- a/t/plugin/ai-proxy-multi.balancer.t
+++ b/t/plugin/ai-proxy-multi.balancer.t
@@ -1033,14 +1033,19 @@ passed
             local uri = "http://127.0.0.1:"; .. ngx.var.server_port
                         .. "/anything"
 
-            -- request once before counting
+            local body = [[{ "messages": [ { "role": "system", "content": "You 
are a mathematician" }, { "role": "user", "content": "What is 1+1?"} ] }]]
+
+            -- Warm-up request bootstraps the active health checker, which the
+            -- plugin creates lazily during instance selection. It must carry a
+            -- valid body so it passes request-type detection and reaches that
+            -- selection step; the 2.2s sleep then lets the checker mark the
+            -- unhealthy instance down before the counted requests run.
             local httpc = http.new()
-            local res, err = httpc:request_uri(uri, {method = "GET"})
+            local res, err = httpc:request_uri(uri, {method = "POST", body = 
body})
             ngx.sleep(2.2)
 
             local restab = {}
 
-            local body = [[{ "messages": [ { "role": "system", "content": "You 
are a mathematician" }, { "role": "user", "content": "What is 1+1?"} ] }]]
             for i = 1, 10 do
                 local httpc = http.new()
                 local query = {
diff --git a/t/plugin/ai-proxy-stream-limits.t 
b/t/plugin/ai-proxy-stream-limits.t
index 6173e9dba..39bdcd022 100644
--- a/t/plugin/ai-proxy-stream-limits.t
+++ b/t/plugin/ai-proxy-stream-limits.t
@@ -429,3 +429,56 @@ Content-Type: application/json
 --- error_code: 502
 --- error_log
 aborting AI response: body size exceeds max_response_bytes 1024
+
+
+
+=== TEST 10: set route with max_req_body_size to bound the request body
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "options": {
+                                "model": "gpt-3.5-turbo"
+                            },
+                            "max_req_body_size": 10,
+                            "override": {
+                                "endpoint": "http://localhost:7740";
+                            },
+                            "ssl_verify": false
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 11: request body larger than max_req_body_size is rejected with 413
+--- request
+POST /anything
+{"messages": [{"role": "user", "content": "this request body is well over ten 
bytes"}]}
+--- more_headers
+Content-Type: application/json
+--- error_code: 413
+--- error_log
+failed to read request body
diff --git a/t/plugin/forward-auth.t b/t/plugin/forward-auth.t
index 450bece82..c07f7dc91 100644
--- a/t/plugin/forward-auth.t
+++ b/t/plugin/forward-auth.t
@@ -518,3 +518,80 @@ Authorization: 111
 --- error_code: 403
 --- error_log
 failed to process forward auth, err: invalid characters found in header value,
+
+
+
+=== TEST 17: setup routes with max_req_body_size for POST body-size limit
+--- config
+    location /t {
+        content_by_lua_block {
+            local data = {
+                {
+                    url = "/apisix/admin/routes/100",
+                    data = [[{
+                        "plugins": {
+                            "forward-auth": {
+                                "uri": "http://127.0.0.1:1984/auth";,
+                                "request_method": "POST",
+                                "max_req_body_size": 10,
+                                "upstream_headers": ["X-User-ID"]
+                            },
+                            "proxy-rewrite": {
+                                "uri": "/echo"
+                            }
+                        },
+                        "upstream_id": "u1",
+                        "uri": "/toolarge"
+                    }]],
+                },
+                {
+                    url = "/apisix/admin/routes/101",
+                    data = [[{
+                        "plugins": {
+                            "forward-auth": {
+                                "uri": "http://127.0.0.1:1984/auth";,
+                                "request_method": "POST",
+                                "max_req_body_size": 1024,
+                                "upstream_headers": ["X-User-ID"]
+                            },
+                            "proxy-rewrite": {
+                                "uri": "/echo"
+                            }
+                        },
+                        "upstream_id": "u1",
+                        "uri": "/withinlimit"
+                    }]],
+                },
+            }
+
+            local t = require("lib.test_admin").test
+            for _, data in ipairs(data) do
+                local code, body = t(data.url, ngx.HTTP_PUT, data.data)
+                ngx.say(body)
+            end
+        }
+    }
+--- request
+GET /t
+--- response_body eval
+"passed\n" x 2
+
+
+
+=== TEST 18: POST body larger than max_req_body_size is rejected with 413
+--- request
+POST /toolarge
+{"authorization":"555"}
+--- error_code: 413
+--- error_log
+failed to read request body
+
+
+
+=== TEST 19: POST body within max_req_body_size is forwarded and authorized
+--- request
+POST /withinlimit
+{"authorization":"555"}
+--- response_body_like eval
+qr/i-am-an-user/
+--- error_code: 200

Reply via email to