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