From e36f180904326c63a692de8bb315f66920fcbabb Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 23 Jun 2026 12:02:15 +0800 Subject: [PATCH 1/3] feat(proxy-rewrite): support multiple values for set/add headers Allow headers.set and headers.add values to be an array (e.g. "foo": ["v1", "v2"]), producing multiple headers with the same name: - set replaces any incoming header with the listed values; - add appends the listed values to the existing ones. The map-based headers schema previously could not express same-name multi-value headers (a JSON object can't hold two values for one key); the array form closes that gap. remove is unchanged (it already takes an array of names). Backward compatible: string/number values keep working. Closes #13163 --- apisix/plugins/proxy-rewrite.lua | 82 ++++++++++++++++--- docs/en/latest/plugins/proxy-rewrite.md | 38 ++++++++- docs/zh/latest/plugins/proxy-rewrite.md | 38 ++++++++- t/lib/server.lua | 23 ++++++ t/plugin/proxy-rewrite.t | 102 ++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 16 deletions(-) diff --git a/apisix/plugins/proxy-rewrite.lua b/apisix/plugins/proxy-rewrite.lua index c1609c612dfa..fff88d65df97 100644 --- a/apisix/plugins/proxy-rewrite.lua +++ b/apisix/plugins/proxy-rewrite.lua @@ -93,7 +93,19 @@ local schema = { ["^[^:]+$"] = { oneOf = { { type = "string" }, - { type = "number" } + { type = "number" }, + { + -- multiple values for the same + -- header name, e.g. ["v1", "v2"] + type = "array", + minItems = 1, + items = { + oneOf = { + { type = "string" }, + { type = "number" }, + } + } + } } } }, @@ -106,6 +118,18 @@ local schema = { oneOf = { { type = "string" }, { type = "number" }, + { + -- replace the header with multiple + -- values, e.g. ["v1", "v2"] + type = "array", + minItems = 1, + items = { + oneOf = { + { type = "string" }, + { type = "number" }, + } + } + } } } }, @@ -275,6 +299,13 @@ do end + local function resolve_header_value(value, ctx) + local val = core.utils.resolve_var_with_captures(value, + ctx.proxy_rewrite_regex_uri_captures) + return core.utils.resolve_var(val, ctx.var) + end + + function _M.rewrite(conf, ctx) for _, name in ipairs(upstream_names) do if conf[name] then @@ -369,22 +400,49 @@ function _M.rewrite(conf, ctx) local field_cnt = #hdr_op.add for i = 1, field_cnt, 2 do - local val = core.utils.resolve_var_with_captures(hdr_op.add[i + 1], - ctx.proxy_rewrite_regex_uri_captures) - val = core.utils.resolve_var(val, ctx.var) - -- A nil or empty table value will cause add_header function to throw an error. - if val then - local header = hdr_op.add[i] - core.request.add_header(ctx, header, val) + local header = hdr_op.add[i] + local value = hdr_op.add[i + 1] + -- an array value adds the header once per element (multiple + -- headers with the same name); a scalar adds it once. + if type(value) == "table" then + for j = 1, #value do + local val = resolve_header_value(value[j], ctx) + -- A nil or empty value will cause add_header to throw. + if val then + core.request.add_header(ctx, header, val) + end + end + else + local val = resolve_header_value(value, ctx) + if val then + core.request.add_header(ctx, header, val) + end end end local field_cnt = #hdr_op.set for i = 1, field_cnt, 2 do - local val = core.utils.resolve_var_with_captures(hdr_op.set[i + 1], - ctx.proxy_rewrite_regex_uri_captures) - val = core.utils.resolve_var(val, ctx.var) - core.request.set_header(ctx, hdr_op.set[i], val) + local header = hdr_op.set[i] + local value = hdr_op.set[i + 1] + -- an array value replaces the header with multiple values in a + -- single set; a scalar sets a single value. + if type(value) == "table" then + local vals = {} + local n = 0 + for j = 1, #value do + local val = resolve_header_value(value[j], ctx) + if val then + n = n + 1 + vals[n] = val + end + end + if n > 0 then + core.request.set_header(ctx, header, vals) + end + else + local val = resolve_header_value(value, ctx) + core.request.set_header(ctx, header, val) + end end local field_cnt = #hdr_op.remove diff --git a/docs/en/latest/plugins/proxy-rewrite.md b/docs/en/latest/plugins/proxy-rewrite.md index d23ce9230320..6a0052480166 100644 --- a/docs/en/latest/plugins/proxy-rewrite.md +++ b/docs/en/latest/plugins/proxy-rewrite.md @@ -45,8 +45,8 @@ The `proxy-rewrite` Plugin offers options to rewrite requests that APISIX forwar | regex_uri | array[string] | False | | | Regular expressions used to match the URI path from client requests and compose a new Upstream URI path. When both `uri` and `regex_uri` are configured, `uri` has a higher priority. The array should contain one or more **key-value pairs**, with the key being the regular expression to match URI against and value being the new Upstream URI path. For example, with `["^/iresty/(. *)/(. *)", "/$1-$2", ^/theothers/*", "/theothers"]`, if a request is originally sent to `/iresty/hello/world`, the Plugin will rewrite the Upstream URI path to `/iresty/hello-world`; if a request is originally sent to `/theothers/hello/world`, the Plugin will rewrite the Upstream URI path to `/theothers`. | | host | string | False | | | Set [`Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) request header. | | headers | object | False | | | Header actions to be executed. Can be set to objects of action verbs `add`, `remove`, and/or `set`; or an object consisting of headers to be `set`. When multiple action verbs are configured, actions are executed in the order of `add`, `remove`, and `set`. | -| headers.add | object | False | | | Headers to append to requests. If a header already present in the request, the header value will be appended. Header value could be set to a constant, one or more [NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or the matched result of `regex_uri` using variables such as `$1-$2-$3`. | -| headers.set | object | False | | | Headers to set to requests. If a header already present in the request, the header value will be overwritten. Header value could be set to a constant, one or more [NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or the matched result of `regex_uri` using variables such as `$1-$2-$3`. Should not be used to set `Host`. | +| headers.add | object | False | | | Headers to append to requests. If a header already present in the request, the header value will be appended. Header value could be set to a constant, one or more [NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or the matched result of `regex_uri` using variables such as `$1-$2-$3`. A value could also be an array of such values (e.g. `["val1", "val2"]`) to append the header multiple times, resulting in multiple headers with the same name. | +| headers.set | object | False | | | Headers to set to requests. If a header already present in the request, the header value will be overwritten. Header value could be set to a constant, one or more [NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or the matched result of `regex_uri` using variables such as `$1-$2-$3`. A value could also be an array of such values (e.g. `["val1", "val2"]`) to replace the header with multiple values (multiple headers with the same name). Should not be used to set `Host`. | | headers.remove | array[string] | False | | | Headers to remove from requests. | use_real_request_uri_unsafe | boolean | False | false | | If true, bypass URI normalization and allow for the full original request URI. Enabling this option is considered unsafe. | @@ -227,6 +227,40 @@ You should see a response similar to the following: Note that both headers present and the header value of `X-Api-Version` configured in the Plugin is appended by the header value passed in the request. +### Set or Append Multiple Values for the Same Header + +Both `set` and `add` accept an array value to produce multiple headers with the same name. Use `set` to replace any incoming header with the listed values, or `add` to append them to the existing ones. + +The following example sets two `X-Api-Version` headers on the upstream request: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "proxy-rewrite-route", + "methods": ["GET"], + "uri": "/", + "plugins": { + "proxy-rewrite": { + "uri": "/headers", + "headers": { + "set": { + "X-Api-Version": ["v1", "v2"] + } + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org:80": 1 + } + } + }' +``` + +The upstream receives `X-Api-Version: v1` and `X-Api-Version: v2`. Replacing `set` with `add` keeps any `X-Api-Version` already present in the client request and appends `v1` and `v2` to it. + ### Remove Existing Header The following example demonstrates how you can remove an existing header `User-Agent`. diff --git a/docs/zh/latest/plugins/proxy-rewrite.md b/docs/zh/latest/plugins/proxy-rewrite.md index 42047dbd7bc3..f8229e07803c 100644 --- a/docs/zh/latest/plugins/proxy-rewrite.md +++ b/docs/zh/latest/plugins/proxy-rewrite.md @@ -45,8 +45,8 @@ description: proxy-rewrite 插件支持重写 APISIX 转发到上游服务的请 | regex_uri | array[string] | 否 | | | 用于匹配客户端请求的 URI 路径并组成新的上游 URI 路径的正则表达式。当同时配置 `uri` 和 `regex_uri` 时,`uri` 具有更高的优先级。该数组应包含一个或多个 **键值对**,其中键是用于匹配 URI 的正则表达式,值是新的上游 URI 路径。例如,对于 `["^/iresty/(. *)/(. *)", "/$1-$2", ^/theothers/*", "/theothers"]`,如果请求最初发送到 `/iresty/hello/world`,插件会将上游 URI 路径重写为 `/iresty/hello-world`;如果请求最初发送到 `/theothers/hello/world`,插件会将上游 URI 路径重写为 `/theothers`。| | host | string | 否 | | | 设置 [`Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) 请求标头。| | headers | object | 否 | | | 要执行的标头操作。可以设置为动作动词 `add`、`remove` 和/或 `set` 的对象;或由要 `set` 的标头组成的对象。当配置了多个动作动词时,动作将按照“添加”、“删除”和“设置”的顺序执行。| -| headers.add | object | 否 | | | 要附加到请求的标头。如果请求中已经存在标头,则会附加标头值。标头值可以设置为常量、一个或多个 [NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。| -| headers.set | object | 否 | | | 要设置请求的标头。如果请求中已经存在标头,则会覆盖标头值。标头值可以设置为常量、一个或多个 [NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。不应将其用于设置 `Host`。| +| headers.add | object | 否 | | | 要附加到请求的标头。如果请求中已经存在标头,则会附加标头值。标头值可以设置为常量、一个或多个 [NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。标头值也可以是上述值的数组(例如 `["val1", "val2"]`),从而多次附加该标头,生成多个同名标头。| +| headers.set | object | 否 | | | 要设置请求的标头。如果请求中已经存在标头,则会覆盖标头值。标头值可以设置为常量、一个或多个 [NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。标头值也可以是上述值的数组(例如 `["val1", "val2"]`),从而将该标头替换为多个值(多个同名标头)。不应将其用于设置 `Host`。| | headers.remove | array[string] | 否 | | | 从请求中删除的标头。 | use_real_request_uri_unsafe | boolean | 否 | false | | 如果为 True,则绕过 URI 规范化并允许完整的原始请求 URI。启用此选项被视为不安全。| @@ -227,6 +227,40 @@ curl "http://127.0.0.1:9080/" -H '"X-Api-Version": "v2"' 请注意,两个标头均存在,并且插件中配置的 `X-Api-Version` 标头值均附加在请求中传递的标头值上。 +### 为同一标头设置或附加多个值 + +`set` 和 `add` 都支持数组值,用于生成多个同名标头。使用 `set` 将传入标头替换为列表中的多个值,或使用 `add` 在已有标头之上追加这些值。 + +以下示例在上游请求上设置两个 `X-Api-Version` 标头: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "proxy-rewrite-route", + "methods": ["GET"], + "uri": "/", + "plugins": { + "proxy-rewrite": { + "uri": "/headers", + "headers": { + "set": { + "X-Api-Version": ["v1", "v2"] + } + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org:80": 1 + } + } + }' +``` + +上游会收到 `X-Api-Version: v1` 和 `X-Api-Version: v2`。将 `set` 替换为 `add` 则保留客户端请求中已有的 `X-Api-Version`,并在其后追加 `v1` 和 `v2`。 + ### 删除现有标头 以下示例演示了如何删除现有标头 `User-Agent`。 diff --git a/t/lib/server.lua b/t/lib/server.lua index 88b8e603efdb..7065bb71d208 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -1258,4 +1258,27 @@ function _M.mock_compressed_upstream_response() end +-- echo received request headers, joining same-name (multi-value) headers with +-- ", " so multi-value proxy-rewrite results are assertable. +function _M.plugin_proxy_rewrite_multi_header() + local headers = ngx.req.get_headers() + + local keys = {} + for k in pairs(headers) do + if not builtin_hdr_ignore_list[k] then + table.insert(keys, k) + end + end + table.sort(keys) + + for _, key in ipairs(keys) do + local v = headers[key] + if type(v) == "table" then + v = table.concat(v, ", ") + end + ngx.say(key, ": ", v) + end +end + + return _M diff --git a/t/plugin/proxy-rewrite.t b/t/plugin/proxy-rewrite.t index 276dd02ed1ea..be8b1c07a0b5 100644 --- a/t/plugin/proxy-rewrite.t +++ b/t/plugin/proxy-rewrite.t @@ -1231,3 +1231,105 @@ GET /hello uri: /uri host: test.com:6443 x-real-ip: 127.0.0.1 + + + +=== TEST 45: set route(set header with multiple values) +--- 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, + [[{ + "plugins": { + "proxy-rewrite": { + "uri": "/plugin_proxy_rewrite_multi_header", + "headers": { + "set": { + "x-multi": ["val1", "val2"] + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 46: set replaces the incoming header with multiple values +--- request +GET /hello +--- more_headers +x-multi: origin +--- response_body_like +x-multi: val1, val2 + + + +=== TEST 47: set route(add header with multiple values) +--- 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, + [[{ + "plugins": { + "proxy-rewrite": { + "uri": "/plugin_proxy_rewrite_multi_header", + "headers": { + "add": { + "x-multi": ["val1", "val2"] + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 48: add appends multiple values to the incoming header +--- request +GET /hello +--- more_headers +x-multi: origin +--- response_body_like +x-multi: origin, val1, val2 From 37cd769ffc764dbc58306b211e871246babd835b Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 24 Jun 2026 09:26:42 +0800 Subject: [PATCH 2/3] test(proxy-rewrite): assert multiple same-name headers as separate lines The echo helper joined same-name headers with ", ", so the assertions ("x-multi: val1, val2") could not tell a genuine multi-header result apart from a single comma-joined value. Emit one line per header occurrence and assert the repeated "x-multi: ..." lines, so the test actually proves that set/add with an array produces multiple headers with the same name. --- t/lib/server.lua | 13 +++++++++---- t/plugin/proxy-rewrite.t | 12 ++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/t/lib/server.lua b/t/lib/server.lua index 7065bb71d208..b1cc75fe0156 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -1258,8 +1258,10 @@ function _M.mock_compressed_upstream_response() end --- echo received request headers, joining same-name (multi-value) headers with --- ", " so multi-value proxy-rewrite results are assertable. +-- echo received request headers, emitting one line per occurrence so that +-- same-name (multi-value) headers are distinguishable from a single +-- comma-joined value. A genuine multi-value header arrives as a table from +-- ngx.req.get_headers() and is printed as repeated "name: value" lines. function _M.plugin_proxy_rewrite_multi_header() local headers = ngx.req.get_headers() @@ -1274,9 +1276,12 @@ function _M.plugin_proxy_rewrite_multi_header() for _, key in ipairs(keys) do local v = headers[key] if type(v) == "table" then - v = table.concat(v, ", ") + for _, item in ipairs(v) do + ngx.say(key, ": ", item) + end + else + ngx.say(key, ": ", v) end - ngx.say(key, ": ", v) end end diff --git a/t/plugin/proxy-rewrite.t b/t/plugin/proxy-rewrite.t index be8b1c07a0b5..30579b70ad89 100644 --- a/t/plugin/proxy-rewrite.t +++ b/t/plugin/proxy-rewrite.t @@ -1275,13 +1275,13 @@ passed -=== TEST 46: set replaces the incoming header with multiple values +=== TEST 46: set replaces the incoming header with multiple same-name headers --- request GET /hello --- more_headers x-multi: origin ---- response_body_like -x-multi: val1, val2 +--- response_body_like eval +qr/x-multi: val1\nx-multi: val2/ @@ -1326,10 +1326,10 @@ passed -=== TEST 48: add appends multiple values to the incoming header +=== TEST 48: add appends multiple same-name headers to the incoming header --- request GET /hello --- more_headers x-multi: origin ---- response_body_like -x-multi: origin, val1, val2 +--- response_body_like eval +qr/x-multi: origin\nx-multi: val1\nx-multi: val2/ From 196ef9b1cbdeceb591b737e0d198a453cff39298 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Fri, 26 Jun 2026 09:01:13 +0800 Subject: [PATCH 3/3] test(proxy-rewrite): cover capture/var in multi-value headers; clarify comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t/plugin/proxy-rewrite.t: add cases where each array element of headers.set/add mixes $1 (regex_uri capture) and $http_x_src (nginx variable), since the docs promise per-element variable/capture support. - proxy-rewrite.lua: clarify the add_header guard comment — it filters nil (which throws); an empty string is a valid value and is intentionally kept. --- apisix/plugins/proxy-rewrite.lua | 4 +- t/plugin/proxy-rewrite.t | 104 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/proxy-rewrite.lua b/apisix/plugins/proxy-rewrite.lua index fff88d65df97..2f6a3922b751 100644 --- a/apisix/plugins/proxy-rewrite.lua +++ b/apisix/plugins/proxy-rewrite.lua @@ -407,7 +407,9 @@ function _M.rewrite(conf, ctx) if type(value) == "table" then for j = 1, #value do local val = resolve_header_value(value[j], ctx) - -- A nil or empty value will cause add_header to throw. + -- guard nil only: add_header throws on nil, while an empty + -- string is a valid value kept to preserve the existing + -- behavior for an unresolved variable/capture. if val then core.request.add_header(ctx, header, val) end diff --git a/t/plugin/proxy-rewrite.t b/t/plugin/proxy-rewrite.t index 30579b70ad89..23f6f8e2a240 100644 --- a/t/plugin/proxy-rewrite.t +++ b/t/plugin/proxy-rewrite.t @@ -1333,3 +1333,107 @@ GET /hello x-multi: origin --- response_body_like eval qr/x-multi: origin\nx-multi: val1\nx-multi: val2/ + + + +=== TEST 49: set route(multi-value with regex_uri capture and nginx variable) +--- 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, + [[{ + "plugins": { + "proxy-rewrite": { + "regex_uri": ["^/test/(.*)", + "/plugin_proxy_rewrite_multi_header"], + "headers": { + "set": { + "x-multi": ["cap-$1", "$http_x_src"] + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/test/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 50: each array element resolves capture and variable +--- request +GET /test/echo +--- more_headers +x-src: from-src +--- response_body_like eval +qr/x-multi: cap-echo\nx-multi: from-src/ + + + +=== TEST 51: add route(multi-value with regex_uri capture and nginx variable) +--- 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, + [[{ + "plugins": { + "proxy-rewrite": { + "regex_uri": ["^/test/(.*)", + "/plugin_proxy_rewrite_multi_header"], + "headers": { + "add": { + "x-multi": ["cap-$1", "$http_x_src"] + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/test/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 52: add resolves capture and variable for each array element +--- request +GET /test/echo +--- more_headers +x-src: from-src +--- response_body_like eval +qr/x-multi: cap-echo\nx-multi: from-src/