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

AlinsRan 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 0e139c790 feat(router): add match_uri_encoded_slash to keep %2F in 
path parameters (#13626)
0e139c790 is described below

commit 0e139c7900628c1f52c598177878f03079e8958e
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 8 14:20:06 2026 +0800

    feat(router): add match_uri_encoded_slash to keep %2F in path parameters 
(#13626)
---
 apisix/cli/config.lua                              |   1 +
 apisix/init.lua                                    |  66 +++++-
 conf/config.yaml.example                           |   5 +
 docs/en/latest/router-radixtree.md                 |  44 ++++
 docs/zh/latest/router-radixtree.md                 |  19 ++
 .../radixtree-uri-with-parameter-encoded-slash.t   | 254 +++++++++++++++++++++
 6 files changed, 388 insertions(+), 1 deletion(-)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 9b8ef0ff1..6537344d7 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -47,6 +47,7 @@ local _M = {
     },
     delete_uri_tail_slash = false,
     normalize_uri_like_servlet = false,
+    match_uri_encoded_slash = false,
     max_post_args_readable_size = 64,
     router = {
       http = "radixtree_host_uri",
diff --git a/apisix/init.lua b/apisix/init.lua
index 51ae3084e..ece57e880 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -58,8 +58,10 @@ local ipairs          = ipairs
 local ngx_now         = ngx.now
 local ngx_var         = ngx.var
 local re_split        = require("ngx.re").split
+local re_gsub         = ngx.re.gsub
 local str_byte        = string.byte
 local str_sub         = string.sub
+local str_char        = string.char
 local tonumber        = tonumber
 local type            = type
 local pairs           = pairs
@@ -487,6 +489,42 @@ local function normalize_uri_like_servlet(uri)
 end
 
 
+-- Percent-decode every %XX in the path. When keep_slash is true, an encoded
+-- slash (%2F/%2f) is left as the literal text "%2F" instead of being turned
+-- into a real path separator -- Nginx decodes it into '/' in $uri, which makes
+-- it indistinguishable from a real separator and breaks path parameter
+-- matching (see issue #11810). The kept slash is always emitted upper-case so
+-- an exact route written with %2F matches regardless of the client's casing.
+local function percent_decode(path, keep_slash)
+    local decoded = re_gsub(path, [[%([0-9a-fA-F][0-9a-fA-F])]], function(m)
+        local hex = m[1]
+        if keep_slash and (hex == "2f" or hex == "2F") then
+            return "%2F"
+        end
+        return str_char(tonumber(hex, 16))
+    end, "jo")
+    return decoded
+end
+
+
+-- Build the route matching uri that keeps the encoded slash (%2F) encoded,
+-- using Nginx's already normalized $uri as an oracle instead of re-doing its
+-- normalization in Lua. If a plain full decode of the raw path reproduces
+-- current_uri ($uri) exactly, Nginx only decoded the request -- it applied no
+-- dot-segment resolution, no slash merging, no fragment stripping and it was
+-- not an absolute-form request line. Only then is it safe to keep %2F encoded,
+-- and the result provably differs from $uri solely by showing some '/' as
+-- "%2F". Anything else (path traversal, consecutive slashes, %00, exotic
+-- request lines) fails the equivalence check and returns nil so the caller
+-- keeps matching on $uri -- no bypass is possible even if the decode is wrong.
+local function build_match_uri_keep_encoded_slash(path, current_uri)
+    if percent_decode(path, false) ~= current_uri then
+        return nil
+    end
+    return percent_decode(path, true)
+end
+
+
 local function common_phase(phase_name)
     local api_ctx = ngx.ctx.api_ctx
     if not api_ctx then
@@ -784,8 +822,34 @@ function _M.http_access_phase()
 
     handle_x_forwarded_headers(api_ctx)
 
+    -- When match_uri_encoded_slash is on, match the route against a uri that
+    -- keeps the encoded slash (%2F) so it is treated as part of a path
+    -- parameter. This is a router-match-only value: it is swapped in just for
+    -- dispatch and restored right after, so the rewrite/access phases, plugins
+    -- and the upstream keep seeing the normalized ctx.var.uri. Only the 
matched
+    -- route and its captured params (uri_param_*) retain the encoded slash.
+    local match_uri
+    if local_conf.apisix and local_conf.apisix.match_uri_encoded_slash then
+        local path = api_ctx.var.real_request_uri
+        local args_pos = core.string.find(path, "?")
+        if args_pos then
+            path = str_sub(path, 1, args_pos - 1)
+        end
+        if core.string.find(path, "%2f") or core.string.find(path, "%2F") then
+            match_uri = build_match_uri_keep_encoded_slash(path, 
api_ctx.var.uri)
+        end
+    end
+
     local match_span = tracer.start(ngx_ctx, "http_router_match", 
tracer.kind.internal)
-    router.router_http.match(api_ctx)
+    if match_uri then
+        local normalized_uri = api_ctx.var.uri
+        api_ctx.var.uri = match_uri
+        router.router_http.match(api_ctx)
+        -- restore so downstream phases never observe the encoded-slash uri
+        api_ctx.var.uri = normalized_uri
+    else
+        router.router_http.match(api_ctx)
+    end
 
     local route = api_ctx.matched_route
     if not route then
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index b7b84d8bb..e16d8ad6e 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -70,6 +70,11 @@ apisix:
   delete_uri_tail_slash: false        # Delete the '/' at the end of the URI
   normalize_uri_like_servlet: false   # If true, use the same path 
normalization rules as the Java
                                       # servlet specification. See 
https://github.com/jakartaee/servlet/blob/master/spec/src/main/asciidoc/servlet-spec-body.adoc#352-uri-path-canonicalization,
 which is used in Tomcat.
+  match_uri_encoded_slash: false       # If true, keep an URL-encoded slash 
(%2F) encoded when matching
+                                      # routes, so it is treated as part of a 
path parameter instead of a
+                                      # path separator. Plugins in the 
rewrite/access phases still read the
+                                      # normalized (decoded) URI from 
ctx.var.uri; nginx forwards the
+                                      # original request line, so the upstream 
receives %2F unchanged.
   max_post_args_readable_size: 64     # Cap (in MB) on the request body read 
when matching `post_arg.*`
                                       # route predicates for JSON and 
multipart requests. Set to 0 to disable the limit.
 
diff --git a/docs/en/latest/router-radixtree.md 
b/docs/en/latest/router-radixtree.md
index 48ff8b167..8b68d9d5c 100644
--- a/docs/en/latest/router-radixtree.md
+++ b/docs/en/latest/router-radixtree.md
@@ -196,6 +196,50 @@ will match both `/blog/dog` and `/blog/cat`.
 
 For more details, see 
https://github.com/api7/lua-resty-radixtree/#parameters-in-path.
 
+By default, an URL-encoded slash (`%2F`) inside a parameter is decoded by Nginx
+into a real `/` before route matching, so a request like `/blog/cat%2Fdog` is
+treated as `/blog/cat/dog` and does not match `/blog/:name`. To keep `%2F`
+encoded during matching (so it is treated as part of the parameter value rather
+than a path separator), enable `match_uri_encoded_slash`:
+
+```yaml
+apisix:
+    match_uri_encoded_slash: true
+    router:
+        http: 'radixtree_uri_with_parameter'
+```
+
+With this enabled, `/blog/cat%2Fdog` matches `/blog/:name` with `name` being
+`cat%2Fdog`. The encoded slash is kept only for route matching and parameter
+capture: plugins in the rewrite/access phases still read the normalized
+(decoded) URI from `ctx.var.uri`. The request line nginx forwards to the
+upstream is the original one, so the upstream receives `%2F` unchanged.
+
+This option is global and changes how every route is matched. Because the
+matching URI keeps `%2F` encoded, an exact route such as `/blog/cat/dog` will 
no
+longer match a request like `/blog/cat%2Fdog` that used to match after Nginx
+decoded the slash. Enable it only when you rely on `%2F` inside path 
parameters.
+
+To stay safe, APISIX does not re-implement Nginx's URI normalization. It keeps
+`%2F` encoded only when a plain full decode of the request path already equals
+the normalized `$uri` — i.e. when Nginx applied nothing beyond 
percent-decoding.
+If the request also required normalization (dot segments such as `..%2F..%2F` 
or
+`%2e%2e`, merged consecutive slashes, an absolute-form request line, etc.), the
+matching URI falls back to the normalized `$uri`. Such requests therefore never
+become an encoded-slash match and cannot bypass route rules via path traversal.
+
+The kept slash is always normalized to upper-case `%2F`, and radixtree compares
+byte-for-byte, so a route whose URI is authored with a lower-case `%2f` (e.g.
+`/blog/a%2fb`) will not match. Write the encoded slash as upper-case `%2F` in
+route URIs.
+
+This option gives way to `delete_uri_tail_slash` and 
`normalize_uri_like_servlet`:
+the equivalence check compares against the URI those options already produced, 
so
+when either actually rewrites the URI (a stripped trailing slash, a 
servlet-style
+`;` parameter) the check no longer holds and the request falls back to normal
+matching without keeping `%2F`. The fallback is safe; the encoded-slash match
+simply does not apply to such requests.
+
 ### 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 0631be23f..6695affba 100644
--- a/docs/zh/latest/router-radixtree.md
+++ b/docs/zh/latest/router-radixtree.md
@@ -212,6 +212,25 @@ apisix:
 
 
更多使用方式请参考:[lua-resty-radixtree#parameters-in-path](https://github.com/api7/lua-resty-radixtree/#parameters-in-path)
 
+默认情况下,参数中的 URL 编码斜杠(`%2F`)会被 Nginx 解码为真实的 `/` 后再进行路由匹配,因此像 `/blog/cat%2Fdog` 
这样的请求会被当作 `/blog/cat/dog`,无法匹配 `/blog/:name`。如果希望在匹配时保留 `%2F` 
编码(即把它作为参数值的一部分,而不是路径分隔符),可以启用 `match_uri_encoded_slash`:
+
+```yaml
+apisix:
+    match_uri_encoded_slash: true
+    router:
+        http: 'radixtree_uri_with_parameter'
+```
+
+启用后,`/blog/cat%2Fdog` 会匹配 `/blog/:name`,此时 `name` 为 
`cat%2Fdog`。编码斜杠仅在路由匹配和参数捕获时保留:rewrite/access 阶段的插件仍从 `ctx.var.uri` 读到归一化(已解码)的 
URI。而 nginx 转发给上游的是原始请求行,因此上游会原样收到 `%2F`。
+
+该选项是全局的,会改变所有路由的匹配方式。由于匹配用的 URI 保留了 `%2F` 编码,像 `/blog/cat/dog` 这样的精确路由将不再匹配此前经 
Nginx 解码斜杠后可匹配的 `/blog/cat%2Fdog` 请求。请仅在确实依赖路径参数中的 `%2F` 时启用。
+
+为保证安全,APISIX 不会自行重造 Nginx 的 URI 归一化逻辑。只有当请求路径「整体全量解码」的结果与归一化后的 `$uri` 
**完全相等**(即 Nginx 除了百分号解码之外没做任何归一化)时,才保留 `%2F` 
编码。如果请求还需要归一化(`..%2F..%2F`、`%2e%2e` 等点段,合并连续斜杠,或 absolute-form 请求行等),匹配用的 URI 
会回退到归一化后的 `$uri`。因此这类请求永远不会变成“保留编码斜杠”的匹配,也无法借助路径穿越绕过路由规则。
+
+保留下来的斜杠会统一归一化为大写 `%2F`,而 radixtree 按字节精确比较,因此路由 URI 若写成小写 `%2f`(例如 
`/blog/a%2fb`)将无法匹配。请在路由 URI 中使用大写 `%2F`。
+
+该选项会让位于 `delete_uri_tail_slash` 和 
`normalize_uri_like_servlet`:等价性检查是与这两个选项处理之后的 URI 比较的,因此当其中任一确实改写了 
URI(去掉末尾斜杠、剥离 servlet 风格的 `;` 参数)时,检查将不再成立,请求会回退到普通匹配而不保留 
`%2F`。这种回退是安全的,只是保留编码斜杠的匹配对这类请求不再生效。
+
 ### 如何通过 Nginx 内置变量过滤路由
 
 具体参数及使用方式请查看 [radixtree#new](https://github.com/api7/lua-resty-radixtree#new) 
文档,下面是一个简单的示例:
diff --git a/t/router/radixtree-uri-with-parameter-encoded-slash.t 
b/t/router/radixtree-uri-with-parameter-encoded-slash.t
new file mode 100644
index 000000000..b64f620e3
--- /dev/null
+++ b/t/router/radixtree-uri-with-parameter-encoded-slash.t
@@ -0,0 +1,254 @@
+#
+# 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();
+
+our $yaml_config = <<_EOC_;
+apisix:
+    node_listen: 1984
+    match_uri_encoded_slash: true
+    router:
+        http: 'radixtree_uri_with_parameter'
+_EOC_
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!$block->yaml_config) {
+        $block->set_value("yaml_config", $yaml_config);
+    }
+
+    # An echo endpoint on the fake upstream that returns the request line it
+    # received, so a test can assert on the URI actually forwarded upstream.
+    if (!$block->upstream_server_config) {
+        $block->set_value("upstream_server_config", <<'_EOC_');
+        location /echo/ {
+            content_by_lua_block {
+                ngx.say(ngx.var.request_uri)
+            }
+        }
+_EOC_
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set routes (path parameter, trailing slash and root)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local routes = {
+                {id = 1, uri = "/v1/:id/products/:type/list"},
+                {id = 2, uri = "/trailing/:name/"},
+                {id = 3, uri = "/"},
+            }
+            for _, r in ipairs(routes) do
+                local code, body = t("/apisix/admin/routes/" .. r.id,
+                    ngx.HTTP_PUT,
+                    {
+                        uri = r.uri,
+                        upstream = {
+                            nodes = {["127.0.0.1:1980"] = 1},
+                            type = "roundrobin",
+                        },
+                    }
+                )
+                if code >= 300 then
+                    ngx.status = code
+                    ngx.say(body)
+                    return
+                end
+            end
+            ngx.say("passed")
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: encoded slash (%2F) in a path parameter is matched (not a 
separator)
+--- request
+GET /v1/te%2Fst/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 3: lowercase encoded slash (%2f) is matched
+--- request
+GET /v1/te%2fst/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 4: a serial number with multiple encoded slashes is matched
+--- request
+GET /v1/2024%2F01%2F0001/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 5: request without encoded slash still matches as before
+--- request
+GET /v1/test/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 6: other percent-encodings are still decoded (%41 -> A), still matches
+--- request
+GET /v1/te%41st/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 7: trailing slash is preserved, so the trailing-slash route matches
+--- request
+GET /trailing/a%2Fb/
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 8: a request that also needs dot-segment normalization falls back to 
$uri
+--- request
+GET /x%2Fy/../..
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 9: an encoded dot segment (%2e) also falls back to $uri
+--- request
+GET /a%2Fb/%2e%2e/%2e%2e
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 10: an encoded slash only in the query string does not rebuild the 
path
+--- request
+GET /?next=%2Fadmin
+--- error_code: 404
+--- error_log
+undefined path in test server, uri: /?next=%2Fadmin
+
+
+
+=== TEST 11: set a route with a plugin that logs the uri it observes
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t("/apisix/admin/routes/10",
+                ngx.HTTP_PUT,
+                {
+                    uri = "/pv/:id/list",
+                    plugins = {
+                        ["serverless-pre-function"] = {
+                            phase = "rewrite",
+                            functions = {
+                                "return function(_, ctx) ngx.log(ngx.WARN, "
+                                .. "'match_uri_view uri=', ctx.var.uri, "
+                                .. "' param=', ctx.var.uri_param_id) end"
+                            },
+                        },
+                    },
+                    upstream = {
+                        nodes = {["127.0.0.1:1980"] = 1},
+                        type = "roundrobin",
+                    },
+                }
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 12: the encoded slash is match-only: plugins see the normalized uri 
while params keep %2F
+--- request
+GET /pv/a%2Fb/list
+--- error_code: 404
+--- error_log
+match_uri_view uri=/pv/a/b/list param=a%2Fb
+
+
+
+=== TEST 13: set an echo route that returns the request line it received
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t("/apisix/admin/routes/20",
+                ngx.HTTP_PUT,
+                {
+                    uri = "/echo/:id",
+                    upstream = {
+                        nodes = {["127.0.0.1:1980"] = 1},
+                        type = "roundrobin",
+                    },
+                }
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 14: the upstream receives the original request line with %2F preserved
+--- request
+GET /echo/a%2Fb
+--- response_body
+/echo/a%2Fb

Reply via email to