You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by al...@apache.org on 2026/06/30 01:53:29 UTC

(apisix) branch feat/encoded-slash-route-match created (now 6e476074d)

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

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


      at 6e476074d feat(router): add preserve_encoded_slash to keep %2F in path parameters

This branch includes the following new commits:

     new 6e476074d feat(router): add preserve_encoded_slash to keep %2F in path parameters

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(apisix) 01/01: feat(router): add preserve_encoded_slash to keep %2F in path parameters

Posted by al...@apache.org.
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 <al...@apache.org>
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/