Skip to content

Commit 0e139c7

Browse files
authored
feat(router): add match_uri_encoded_slash to keep %2F in path parameters (#13626)
1 parent dadce3a commit 0e139c7

6 files changed

Lines changed: 388 additions & 1 deletion

File tree

apisix/cli/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ local _M = {
4747
},
4848
delete_uri_tail_slash = false,
4949
normalize_uri_like_servlet = false,
50+
match_uri_encoded_slash = false,
5051
max_post_args_readable_size = 64,
5152
router = {
5253
http = "radixtree_host_uri",

apisix/init.lua

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ local ipairs = ipairs
5858
local ngx_now = ngx.now
5959
local ngx_var = ngx.var
6060
local re_split = require("ngx.re").split
61+
local re_gsub = ngx.re.gsub
6162
local str_byte = string.byte
6263
local str_sub = string.sub
64+
local str_char = string.char
6365
local tonumber = tonumber
6466
local type = type
6567
local pairs = pairs
@@ -487,6 +489,42 @@ local function normalize_uri_like_servlet(uri)
487489
end
488490

489491

492+
-- Percent-decode every %XX in the path. When keep_slash is true, an encoded
493+
-- slash (%2F/%2f) is left as the literal text "%2F" instead of being turned
494+
-- into a real path separator -- Nginx decodes it into '/' in $uri, which makes
495+
-- it indistinguishable from a real separator and breaks path parameter
496+
-- matching (see issue #11810). The kept slash is always emitted upper-case so
497+
-- an exact route written with %2F matches regardless of the client's casing.
498+
local function percent_decode(path, keep_slash)
499+
local decoded = re_gsub(path, [[%([0-9a-fA-F][0-9a-fA-F])]], function(m)
500+
local hex = m[1]
501+
if keep_slash and (hex == "2f" or hex == "2F") then
502+
return "%2F"
503+
end
504+
return str_char(tonumber(hex, 16))
505+
end, "jo")
506+
return decoded
507+
end
508+
509+
510+
-- Build the route matching uri that keeps the encoded slash (%2F) encoded,
511+
-- using Nginx's already normalized $uri as an oracle instead of re-doing its
512+
-- normalization in Lua. If a plain full decode of the raw path reproduces
513+
-- current_uri ($uri) exactly, Nginx only decoded the request -- it applied no
514+
-- dot-segment resolution, no slash merging, no fragment stripping and it was
515+
-- not an absolute-form request line. Only then is it safe to keep %2F encoded,
516+
-- and the result provably differs from $uri solely by showing some '/' as
517+
-- "%2F". Anything else (path traversal, consecutive slashes, %00, exotic
518+
-- request lines) fails the equivalence check and returns nil so the caller
519+
-- keeps matching on $uri -- no bypass is possible even if the decode is wrong.
520+
local function build_match_uri_keep_encoded_slash(path, current_uri)
521+
if percent_decode(path, false) ~= current_uri then
522+
return nil
523+
end
524+
return percent_decode(path, true)
525+
end
526+
527+
490528
local function common_phase(phase_name)
491529
local api_ctx = ngx.ctx.api_ctx
492530
if not api_ctx then
@@ -784,8 +822,34 @@ function _M.http_access_phase()
784822

785823
handle_x_forwarded_headers(api_ctx)
786824

825+
-- When match_uri_encoded_slash is on, match the route against a uri that
826+
-- keeps the encoded slash (%2F) so it is treated as part of a path
827+
-- parameter. This is a router-match-only value: it is swapped in just for
828+
-- dispatch and restored right after, so the rewrite/access phases, plugins
829+
-- and the upstream keep seeing the normalized ctx.var.uri. Only the matched
830+
-- route and its captured params (uri_param_*) retain the encoded slash.
831+
local match_uri
832+
if local_conf.apisix and local_conf.apisix.match_uri_encoded_slash then
833+
local path = api_ctx.var.real_request_uri
834+
local args_pos = core.string.find(path, "?")
835+
if args_pos then
836+
path = str_sub(path, 1, args_pos - 1)
837+
end
838+
if core.string.find(path, "%2f") or core.string.find(path, "%2F") then
839+
match_uri = build_match_uri_keep_encoded_slash(path, api_ctx.var.uri)
840+
end
841+
end
842+
787843
local match_span = tracer.start(ngx_ctx, "http_router_match", tracer.kind.internal)
788-
router.router_http.match(api_ctx)
844+
if match_uri then
845+
local normalized_uri = api_ctx.var.uri
846+
api_ctx.var.uri = match_uri
847+
router.router_http.match(api_ctx)
848+
-- restore so downstream phases never observe the encoded-slash uri
849+
api_ctx.var.uri = normalized_uri
850+
else
851+
router.router_http.match(api_ctx)
852+
end
789853

790854
local route = api_ctx.matched_route
791855
if not route then

conf/config.yaml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ apisix:
7070
delete_uri_tail_slash: false # Delete the '/' at the end of the URI
7171
normalize_uri_like_servlet: false # If true, use the same path normalization rules as the Java
7272
# 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.
73+
match_uri_encoded_slash: false # If true, keep an URL-encoded slash (%2F) encoded when matching
74+
# routes, so it is treated as part of a path parameter instead of a
75+
# path separator. Plugins in the rewrite/access phases still read the
76+
# normalized (decoded) URI from ctx.var.uri; nginx forwards the
77+
# original request line, so the upstream receives %2F unchanged.
7378
max_post_args_readable_size: 64 # Cap (in MB) on the request body read when matching `post_arg.*`
7479
# route predicates for JSON and multipart requests. Set to 0 to disable the limit.
7580

docs/en/latest/router-radixtree.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,50 @@ will match both `/blog/dog` and `/blog/cat`.
196196

197197
For more details, see https://github.com/api7/lua-resty-radixtree/#parameters-in-path.
198198

199+
By default, an URL-encoded slash (`%2F`) inside a parameter is decoded by Nginx
200+
into a real `/` before route matching, so a request like `/blog/cat%2Fdog` is
201+
treated as `/blog/cat/dog` and does not match `/blog/:name`. To keep `%2F`
202+
encoded during matching (so it is treated as part of the parameter value rather
203+
than a path separator), enable `match_uri_encoded_slash`:
204+
205+
```yaml
206+
apisix:
207+
match_uri_encoded_slash: true
208+
router:
209+
http: 'radixtree_uri_with_parameter'
210+
```
211+
212+
With this enabled, `/blog/cat%2Fdog` matches `/blog/:name` with `name` being
213+
`cat%2Fdog`. The encoded slash is kept only for route matching and parameter
214+
capture: plugins in the rewrite/access phases still read the normalized
215+
(decoded) URI from `ctx.var.uri`. The request line nginx forwards to the
216+
upstream is the original one, so the upstream receives `%2F` unchanged.
217+
218+
This option is global and changes how every route is matched. Because the
219+
matching URI keeps `%2F` encoded, an exact route such as `/blog/cat/dog` will no
220+
longer match a request like `/blog/cat%2Fdog` that used to match after Nginx
221+
decoded the slash. Enable it only when you rely on `%2F` inside path parameters.
222+
223+
To stay safe, APISIX does not re-implement Nginx's URI normalization. It keeps
224+
`%2F` encoded only when a plain full decode of the request path already equals
225+
the normalized `$uri` — i.e. when Nginx applied nothing beyond percent-decoding.
226+
If the request also required normalization (dot segments such as `..%2F..%2F` or
227+
`%2e%2e`, merged consecutive slashes, an absolute-form request line, etc.), the
228+
matching URI falls back to the normalized `$uri`. Such requests therefore never
229+
become an encoded-slash match and cannot bypass route rules via path traversal.
230+
231+
The kept slash is always normalized to upper-case `%2F`, and radixtree compares
232+
byte-for-byte, so a route whose URI is authored with a lower-case `%2f` (e.g.
233+
`/blog/a%2fb`) will not match. Write the encoded slash as upper-case `%2F` in
234+
route URIs.
235+
236+
This option gives way to `delete_uri_tail_slash` and `normalize_uri_like_servlet`:
237+
the equivalence check compares against the URI those options already produced, so
238+
when either actually rewrites the URI (a stripped trailing slash, a servlet-style
239+
`;` parameter) the check no longer holds and the request falls back to normal
240+
matching without keeping `%2F`. The fallback is safe; the encoded-slash match
241+
simply does not apply to such requests.
242+
199243
### How to filter route by Nginx built-in variable?
200244

201245
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:

docs/zh/latest/router-radixtree.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,25 @@ apisix:
212212

213213
更多使用方式请参考:[lua-resty-radixtree#parameters-in-path](https://github.com/api7/lua-resty-radixtree/#parameters-in-path)
214214

215+
默认情况下,参数中的 URL 编码斜杠(`%2F`)会被 Nginx 解码为真实的 `/` 后再进行路由匹配,因此像 `/blog/cat%2Fdog` 这样的请求会被当作 `/blog/cat/dog`,无法匹配 `/blog/:name`。如果希望在匹配时保留 `%2F` 编码(即把它作为参数值的一部分,而不是路径分隔符),可以启用 `match_uri_encoded_slash`
216+
217+
```yaml
218+
apisix:
219+
match_uri_encoded_slash: true
220+
router:
221+
http: 'radixtree_uri_with_parameter'
222+
```
223+
224+
启用后,`/blog/cat%2Fdog` 会匹配 `/blog/:name`,此时 `name` 为 `cat%2Fdog`。编码斜杠仅在路由匹配和参数捕获时保留:rewrite/access 阶段的插件仍从 `ctx.var.uri` 读到归一化(已解码)的 URI。而 nginx 转发给上游的是原始请求行,因此上游会原样收到 `%2F`。
225+
226+
该选项是全局的,会改变所有路由的匹配方式。由于匹配用的 URI 保留了 `%2F` 编码,像 `/blog/cat/dog` 这样的精确路由将不再匹配此前经 Nginx 解码斜杠后可匹配的 `/blog/cat%2Fdog` 请求。请仅在确实依赖路径参数中的 `%2F` 时启用。
227+
228+
为保证安全,APISIX 不会自行重造 Nginx 的 URI 归一化逻辑。只有当请求路径「整体全量解码」的结果与归一化后的 `$uri` **完全相等**(即 Nginx 除了百分号解码之外没做任何归一化)时,才保留 `%2F` 编码。如果请求还需要归一化(`..%2F..%2F`、`%2e%2e` 等点段,合并连续斜杠,或 absolute-form 请求行等),匹配用的 URI 会回退到归一化后的 `$uri`。因此这类请求永远不会变成“保留编码斜杠”的匹配,也无法借助路径穿越绕过路由规则。
229+
230+
保留下来的斜杠会统一归一化为大写 `%2F`,而 radixtree 按字节精确比较,因此路由 URI 若写成小写 `%2f`(例如 `/blog/a%2fb`)将无法匹配。请在路由 URI 中使用大写 `%2F`。
231+
232+
该选项会让位于 `delete_uri_tail_slash` 和 `normalize_uri_like_servlet`:等价性检查是与这两个选项处理之后的 URI 比较的,因此当其中任一确实改写了 URI(去掉末尾斜杠、剥离 servlet 风格的 `;` 参数)时,检查将不再成立,请求会回退到普通匹配而不保留 `%2F`。这种回退是安全的,只是保留编码斜杠的匹配对这类请求不再生效。
233+
215234
### 如何通过 Nginx 内置变量过滤路由
216235

217236
具体参数及使用方式请查看 [radixtree#new](https://github.com/api7/lua-resty-radixtree#new) 文档,下面是一个简单的示例:

0 commit comments

Comments
 (0)