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:30 UTC
(apisix) 01/01: feat(router): add preserve_encoded_slash to keep %2F in path parameters
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/