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/