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 <[email protected]> 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/
