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