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 4a080d0293 fix(router): forward the matched path when nginx normalizes
the request uri (#14035)
4a080d0293 is described below
commit 4a080d02931ce9fc92dea1b282595666c20c0229
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Mon Oct 26 16:49:35 2026 +0800
fix(router): forward the matched path when nginx normalizes the request uri
(#14035)
---
apisix/init.lua | 31 +++
docs/en/latest/router-radixtree.md | 21 ++
docs/zh/latest/router-radixtree.md | 10 +
t/router/radixtree-uri-normalized-upstream.t | 396 +++++++++++++++++++++++++++
4 files changed, 458 insertions(+)
diff --git a/apisix/init.lua b/apisix/init.lua
index 7f8a00609b..57898b69a5 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -602,6 +602,30 @@ local function build_match_uri_keep_encoded_slash(path,
current_uri)
end
+-- whether nginx did more than decode the path or merge slashes in $uri,
+-- e.g. resolved a dot segment or cut the path at "#"
+local function uri_normalized_by_nginx(request_uri, uri)
+ if request_uri == uri then
+ return false
+ end
+ if core.string.has_prefix(request_uri, uri)
+ and str_byte(request_uri, #uri + 1) == str_byte("?") then
+ return false
+ end
+
+ local path = request_uri
+ local args_pos = core.string.find(path, "?")
+ if args_pos then
+ path = str_sub(path, 1, args_pos - 1)
+ end
+ local decoded = percent_decode(path, false)
+ if decoded == uri then
+ return false
+ end
+ return re_gsub(decoded, [[//+]], "/", "jo") ~= uri
+end
+
+
local function common_phase(phase_name)
local api_ctx = ngx.ctx.api_ctx
if not api_ctx then
@@ -898,6 +922,13 @@ function _M.http_access_phase()
end
end
+ -- forward the path the route was matched on; binary allows decoded
+ -- control chars, proxy_pass escapes them
+ if not (local_conf.apisix and local_conf.apisix.normalize_uri_like_servlet)
+ and uri_normalized_by_nginx(api_ctx.var.request_uri, uri) then
+ ngx.req.set_uri(uri, false, true)
+ end
+
-- To prevent being hacked by untrusted request_uri, here we
-- record the normalized but not rewritten uri as request_uri,
-- the original request_uri can be accessed via var.real_request_uri
diff --git a/docs/en/latest/router-radixtree.md
b/docs/en/latest/router-radixtree.md
index 8b68d9d5c9..a4390af249 100644
--- a/docs/en/latest/router-radixtree.md
+++ b/docs/en/latest/router-radixtree.md
@@ -240,6 +240,27 @@ when either actually rewrites the URI (a stripped trailing
slash, a servlet-styl
matching without keeping `%2F`. The fallback is safe; the encoded-slash match
simply does not apply to such requests.
+#### 6. Path forwarded to the upstream
+
+Routes are matched on the normalized `$uri`: Nginx percent-decodes the path,
+resolves dot segments (including encoded ones such as `..%2F` and `%2e%2e`),
+cuts the path at `#` and merges consecutive slashes. When the request path
+differs from `$uri` only by percent-encoding or consecutive slashes, APISIX
+forwards the original request line to the upstream. Otherwise it forwards the
+matched path, escaped by Nginx, with the current query string. For example, a
+request to `/admin/..%2Fapi/x` matches a route for `/api/*` and the upstream
+receives `/api/x`.
+
+In that case encoded delimiters arrive decoded: `/api/./a%2Fb%3Bc` reaches the
+upstream as `/api/a/b;c`, while `/api/a%2Fb` is forwarded as is.
+
+Changes that plugins make to the path or the query string after matching still
+apply, and plugins that set the upstream URI, such as `proxy-rewrite`, still
+take precedence.
+
+This section does not apply when `normalize_uri_like_servlet` is enabled, which
+sets the upstream path itself.
+
### How to filter route by Nginx built-in variable?
Nginx provides a variety of built-in variables that can be used to filter
routes based on certain criteria. Here is an example of how to filter routes by
Nginx built-in variables:
diff --git a/docs/zh/latest/router-radixtree.md
b/docs/zh/latest/router-radixtree.md
index 6695affba2..ba3bfb2cd6 100644
--- a/docs/zh/latest/router-radixtree.md
+++ b/docs/zh/latest/router-radixtree.md
@@ -231,6 +231,16 @@ apisix:
该选项会让位于 `delete_uri_tail_slash` 和
`normalize_uri_like_servlet`:等价性检查是与这两个选项处理之后的 URI 比较的,因此当其中任一确实改写了
URI(去掉末尾斜杠、剥离 servlet 风格的 `;` 参数)时,检查将不再成立,请求会回退到普通匹配而不保留
`%2F`。这种回退是安全的,只是保留编码斜杠的匹配对这类请求不再生效。
+#### 6. 转发给上游的路径
+
+路由基于归一化后的 `$uri` 进行匹配:Nginx 会对路径进行百分号解码、解析点段(包括 `..%2F`、`%2e%2e` 等编码形式)、在 `#`
处截断路径并合并连续斜杠。当请求路径与 `$uri` 仅在百分号编码或连续斜杠上存在差异时,APISIX 将原始请求行转发给上游。否则,APISIX 会转发经
Nginx 转义的匹配路径,并附带当前的查询字符串。例如,请求 `/admin/..%2Fapi/x` 会匹配 `/api/*` 路由,上游收到的是
`/api/x`。
+
+此时编码的分隔符会以解码后的形式到达上游:`/api/./a%2Fb%3Bc` 到达上游时为 `/api/a/b;c`,而 `/api/a%2Fb`
会原样转发。
+
+插件在匹配之后对路径或查询字符串所做的修改仍然生效,设置上游 URI 的插件(如 `proxy-rewrite`)仍然优先生效。
+
+启用 `normalize_uri_like_servlet` 时,本节不适用,该选项会自行设置上游路径。
+
### 如何通过 Nginx 内置变量过滤路由
具体参数及使用方式请查看 [radixtree#new](https://github.com/api7/lua-resty-radixtree#new)
文档,下面是一个简单的示例:
diff --git a/t/router/radixtree-uri-normalized-upstream.t
b/t/router/radixtree-uri-normalized-upstream.t
new file mode 100644
index 0000000000..9dec522108
--- /dev/null
+++ b/t/router/radixtree-uri-normalized-upstream.t
@@ -0,0 +1,396 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('info');
+worker_connections(256);
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->upstream_server_config) {
+ $block->set_value("upstream_server_config", <<'_EOC_');
+ location /admin/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location /api/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location = /fixed {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location /ka/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location /svl/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location /sva/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location = / {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+_EOC_
+ }
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set routes with and without auth on the same upstream
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local routes = {
+ {
+ id = "admin",
+ uri = "/admin/*",
+ plugins = {
+ ["key-auth"] = {},
+ },
+ },
+ {
+ id = "api",
+ uri = "/api/*",
+ },
+ {
+ id = "rewrite",
+ uri = "/rewrite/*",
+ plugins = {
+ ["proxy-rewrite"] = {
+ uri = "/fixed",
+ },
+ },
+ },
+ }
+
+ for _, route in ipairs(routes) do
+ route.upstream = {
+ nodes = { ["127.0.0.1:1980"] = 1 },
+ type = "roundrobin",
+ }
+
+ local code, body = t("/apisix/admin/routes/" .. route.id,
+ ngx.HTTP_PUT, route)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: route with key-auth requires an API key
+--- request
+GET /admin/x
+--- error_code: 401
+--- response_body
+{"message":"Missing API key in request"}
+
+
+
+=== TEST 3: encoded slash dot segment forwards the matched path
+--- request
+GET /admin/..%2Fapi/x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 4: encoded dot segment forwards the matched path with args
+--- request
+GET /admin/%2e%2e/api/x?a=1&b=%2F
+--- response_body
+request-uri=/api/x?a=1&b=%2F
+
+
+
+=== TEST 5: plain dot segment forwards the matched path
+--- request
+GET /admin/../api/x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 6: merged slashes alone are forwarded unchanged
+--- request
+GET /api/https://example.com//x
+--- response_body
+request-uri=/api/https://example.com//x
+
+
+
+=== TEST 7: encoded path without normalization is forwarded unchanged
+--- request
+GET /api/a%2Fb%20c?x=1
+--- response_body
+request-uri=/api/a%2Fb%20c?x=1
+
+
+
+=== TEST 8: proxy-rewrite uri still takes precedence
+--- request
+GET /rewrite/a/..%2Fb
+--- response_body
+request-uri=/fixed
+
+
+
+=== TEST 9: remove the routes
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ for _, id in ipairs({"admin", "api", "rewrite"}) do
+ local code, body = t("/apisix/admin/routes/" .. id,
ngx.HTTP_DELETE)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 10: set routes whose plugins rewrite the request after matching
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t("/apisix/admin/consumers", ngx.HTTP_PUT, {
+ username = "jack",
+ plugins = {
+ ["key-auth"] = { key = "auth-one" },
+ },
+ })
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local routes = {
+ {
+ id = "ka",
+ uri = "/ka/*",
+ plugins = {
+ ["key-auth"] = { hide_credentials = true },
+ },
+ },
+ {
+ id = "svl",
+ uri = "/svl/*",
+ plugins = {
+ ["serverless-pre-function"] = {
+ phase = "access",
+ functions = {
+ "return function()
ngx.req.set_uri('/svl/changed') end",
+ },
+ },
+ },
+ },
+ {
+ id = "sva",
+ uri = "/sva/*",
+ plugins = {
+ ["serverless-pre-function"] = {
+ phase = "access",
+ functions = {
+ "return function()
ngx.req.set_uri_args('added=1') end",
+ },
+ },
+ },
+ },
+ {
+ id = "api",
+ uri = "/api/*",
+ },
+ {
+ id = "root",
+ uri = "/",
+ },
+ }
+
+ for _, route in ipairs(routes) do
+ route.upstream = {
+ nodes = { ["127.0.0.1:1980"] = 1 },
+ type = "roundrobin",
+ }
+
+ code, body = t("/apisix/admin/routes/" .. route.id,
+ ngx.HTTP_PUT, route)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 11: hidden query credential is removed from a dot segment request
+--- request
+GET /ka/./x?apikey=auth-one&a=1
+--- response_body
+request-uri=/ka/x?a=1
+
+
+
+=== TEST 12: path set by a plugin is forwarded
+--- request
+GET /svl/./x
+--- response_body
+request-uri=/svl/changed
+
+
+
+=== TEST 13: args set by a plugin are forwarded
+--- request
+GET /sva/./x?b=2
+--- response_body
+request-uri=/sva/x?added=1
+
+
+
+=== TEST 14: single dot segment forwards the matched path
+--- request
+GET /api/./x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 15: path cut at "#" forwards the matched path
+--- raw_request eval
+"GET /admin/..#x HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
+--- response_body
+request-uri=/
+
+
+
+=== TEST 16: decoded control char in the matched path is escaped
+--- request
+GET /api/./a%0Ab
+--- response_body
+request-uri=/api/a%0Ab
+
+
+
+=== TEST 17: trailing slash is kept with delete_uri_tail_slash
+--- yaml_config
+apisix:
+ node_listen: 1984
+ delete_uri_tail_slash: true
+ router:
+ http: 'radixtree_uri'
+--- request
+GET /api/./x/
+--- response_body
+request-uri=/api/x/
+
+
+
+=== TEST 18: servlet mode forwards the matched path
+--- yaml_config
+apisix:
+ node_listen: 1984
+ normalize_uri_like_servlet: true
+ router:
+ http: 'radixtree_uri'
+--- request
+GET /api/./x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 19: remove the routes and the consumer
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ for _, id in ipairs({"ka", "svl", "sva", "api", "root"}) do
+ local code, body = t("/apisix/admin/routes/" .. id,
ngx.HTTP_DELETE)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+
+ local code, body = t("/apisix/admin/consumers/jack",
ngx.HTTP_DELETE)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed