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

AlinsRan pushed a commit to branch feat/encoded-slash-route-match
in repository https://gitbox.apache.org/repos/asf/apisix.git

commit 6e476074dcad77c72ee68dc43914a7fdfa0c8bdc
Author: AlinsRan <[email protected]>
AuthorDate: Mon Jun 29 16:54:33 2026 +0800

    feat(router): add preserve_encoded_slash to keep %2F in path parameters
    
    Nginx decodes an URL-encoded slash (%2F) into a real '/' in $uri before
    route matching, so a request like /v1/te%2Fst/products/electronics/list is
    seen as /v1/te/st/products/electronics/list and fails to match a route with
    path parameters such as /v1/:id/products/:type/list.
    
    Add an opt-in apisix.preserve_encoded_slash option. When enabled, the route
    matching uri is rebuilt from the raw request_uri with every percent-encoding
    decoded except %2F, which stays encoded so it is treated as part of a path
    parameter instead of a separator. The encoded slash is then forwarded to the
    upstream as is. '.' and '..' segments are still normalized to prevent path
    traversal, and a decoded null byte is rejected. The rebuild only runs when 
an
    encoded slash is present, so normal requests keep using the normalized $uri
    with no overhead.
    
    Closes #11810
---
 apisix/cli/config.lua                              |   1 +
 apisix/init.lua                                    |  91 ++++++++++++++
 conf/config.yaml.example                           |   3 +
 docs/en/latest/router-radixtree.md                 |  17 +++
 .../radixtree-uri-with-parameter-encoded-slash.t   | 135 +++++++++++++++++++++
 5 files changed, 247 insertions(+)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 9b8ef0ff1..3e742ba66 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,
+    preserve_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 32c9dfa08..ac04f5373 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -58,8 +58,11 @@ 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_find        = string.find
+local str_char        = string.char
 local tonumber        = tonumber
 local type            = type
 local pairs           = pairs
@@ -487,6 +490,79 @@ local function normalize_uri_like_servlet(uri)
 end
 
 
+-- Decode percent-encodings in the path but keep an encoded slash (%2F/%2f)
+-- encoded, so it is not turned into a real path separator. Nginx decodes
+-- %2F into '/' in $uri, which makes it indistinguishable from a real
+-- separator and breaks path parameter matching (see issue #11810).
+local function decode_uri_keep_encoded_slash(path)
+    return (re_gsub(path, [[%([0-9a-fA-F][0-9a-fA-F])]], function(m)
+        local hex = m[1]
+        if hex == "2f" or hex == "2F" then
+            -- keep the encoded slash as is
+            return "%" .. hex
+        end
+        return str_char(tonumber(hex, 16))
+    end, "jo"))
+end
+
+
+-- Resolve "." and ".." segments like Nginx does for $uri. Because we build
+-- the matching uri from the raw request_uri (instead of the already
+-- normalized $uri), we must redo this normalization ourselves to keep route
+-- matching consistent and to prevent path traversal bypass. Segments are
+-- split on real slashes only, so an encoded slash stays inside its segment.
+local function remove_dot_segments(path)
+    local segs, err = re_split(path, "/", "jo")
+    if not segs then
+        return nil, err
+    end
+
+    local out = {}
+    local len = #segs
+    for i = 1, len do
+        local seg = segs[i]
+        if seg == "." then
+            -- drop, but keep the trailing slash if it is the last segment
+            if i == len then
+                out[#out + 1] = ""
+            end
+        elseif seg == ".." then
+            -- pop the previous segment, but never go above the root (out[1])
+            if #out > 1 then
+                out[#out] = nil
+            end
+            if i == len then
+                out[#out + 1] = ""
+            end
+        else
+            out[#out + 1] = seg
+        end
+    end
+
+    return core.table.concat(out, "/")
+end
+
+
+-- Build the route matching uri from the raw request_uri while keeping the
+-- encoded slash (%2F) so it is matched as part of a path parameter rather
+-- than a separator.
+local function normalize_uri_keep_encoded_slash(request_uri)
+    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 = decode_uri_keep_encoded_slash(path)
+    -- reject a decoded null byte, which Nginx rejects when producing $uri
+    if str_find(decoded, "\0", 1, true) then
+        return nil, "uri contains null byte"
+    end
+
+    return remove_dot_segments(decoded)
+end
+
+
 local function common_phase(phase_name)
     local api_ctx = ngx.ctx.api_ctx
     if not api_ctx then
@@ -763,6 +839,21 @@ function _M.http_access_phase()
             -- can consume the param after ';'
             api_ctx.var.upstream_uri = uri
         end
+
+        if local_conf.apisix.preserve_encoded_slash then
+            -- only rebuild the uri when an encoded slash is present, so normal
+            -- requests keep using the already normalized $uri with no overhead
+            local request_uri = api_ctx.var.request_uri
+            if request_uri and (str_find(request_uri, "%2f", 1, true)
+                                or str_find(request_uri, "%2F", 1, true)) then
+                local new_uri, err = 
normalize_uri_keep_encoded_slash(request_uri)
+                if not new_uri then
+                    core.log.error("failed to normalize uri with encoded 
slash: ", err)
+                    return core.response.exit(400)
+                end
+                api_ctx.var.uri = new_uri
+            end
+        end
     end
 
     -- To prevent being hacked by untrusted request_uri, here we
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index f83b0e0f8..d389800f8 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -70,6 +70,9 @@ 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.
+  preserve_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. The encoded slash is 
also forwarded to the upstream.
   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..194279914 100644
--- a/docs/en/latest/router-radixtree.md
+++ b/docs/en/latest/router-radixtree.md
@@ -196,6 +196,23 @@ 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 `preserve_encoded_slash`:
+
+```yaml
+apisix:
+    preserve_encoded_slash: true
+    router:
+        http: 'radixtree_uri_with_parameter'
+```
+
+With this enabled, `/blog/cat%2Fdog` matches `/blog/:name` with `name` being
+`cat%2Fdog`, and the encoded slash is forwarded to the upstream as is. `.` and
+`..` segments are still normalized to prevent path traversal.
+
 ### 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/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..8bd0ec283
--- /dev/null
+++ b/t/router/radixtree-uri-with-parameter-encoded-slash.t
@@ -0,0 +1,135 @@
+#
+# 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
+    preserve_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);
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set route with path parameters
+--- 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,
+                [[{
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/v1/:id/products/:type/list"
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- 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 (%20), still matches
+--- request
+GET /v1/te%20st/products/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 7: dot segments are normalized, route still matches
+--- request
+GET /v1/te%2Fst/products/foo/../electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/
+
+
+
+=== TEST 8: encoded dot segment (%2e) is normalized, route still matches
+--- request
+GET /v1/te%2Fst/products/%2e/electronics/list
+--- error_code: 404
+--- response_body eval
+qr/404 Not Found/

Reply via email to