This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek 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 bf40a99c58 fix(ssl): match skip_mtls_uri_regex against the 
encoded-slash route uri (#14108)
bf40a99c58 is described below

commit bf40a99c581ce161c3b9da2bed364ad27f349ae1
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Oct 30 14:12:10 2026 +0800

    fix(ssl): match skip_mtls_uri_regex against the encoded-slash route uri 
(#14108)
---
 apisix/init.lua                    | 122 ++++++++++++++++----------
 docs/en/latest/admin-api.md        |   2 +-
 docs/zh/latest/admin-api.md        |   2 +-
 t/node/client-mtls-encoded-slash.t | 174 +++++++++++++++++++++++++++++++++++++
 4 files changed, 250 insertions(+), 50 deletions(-)

diff --git a/apisix/init.lua b/apisix/init.lua
index 57898b69a5..adf21a3cd9 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -442,6 +442,61 @@ local function verify_tls_client(ctx)
 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
+
+
+-- route match uri that keeps %2F, or nil when it does not apply
+local function encoded_slash_match_uri(request_uri, uri)
+    if not (local_conf.apisix and local_conf.apisix.match_uri_encoded_slash) 
then
+        return nil
+    end
+
+    local path = request_uri
+    local args_pos = core.string.find(path, "?")
+    if args_pos then
+        path = str_sub(path, 1, args_pos - 1)
+    end
+    if not (core.string.find(path, "%2f") or core.string.find(path, "%2F")) 
then
+        return nil
+    end
+
+    return build_match_uri_keep_encoded_slash(path, uri)
+end
+
+
 local function uri_matches_skip_mtls_route_patterns(ssl, uri)
     for _, pat in ipairs(ssl.value.client.skip_mtls_uri_regex) do
         if ngx_re_match(uri, pat,  "jo") then
@@ -451,6 +506,22 @@ local function uri_matches_skip_mtls_route_patterns(ssl, 
uri)
 end
 
 
+local function skip_mtls(ssl, ctx)
+    local uri = ngx.var.uri
+    if not uri_matches_skip_mtls_route_patterns(ssl, uri) then
+        return false
+    end
+
+    -- the router sees %2F as part of a segment, check that form too
+    local match_uri = encoded_slash_match_uri(ctx.var.request_uri, uri)
+    if match_uri and not uri_matches_skip_mtls_route_patterns(ssl, match_uri) 
then
+        return false
+    end
+
+    return true
+end
+
+
 local function verify_https_client(ctx)
     local scheme = ctx.var.scheme
     if scheme ~= "https" then
@@ -461,7 +532,7 @@ local function verify_https_client(ctx)
     if matched_ssl.value.client
         and matched_ssl.value.client.skip_mtls_uri_regex
         and apisix_ssl.support_client_verification()
-        and (not uri_matches_skip_mtls_route_patterns(matched_ssl, 
ngx.var.uri)) then
+        and (not skip_mtls(matched_ssl, ctx)) then
         local res = ctx.var.ssl_client_verify
         if res ~= "SUCCESS" then
             if res == "NONE" then
@@ -566,42 +637,6 @@ local function set_servlet_upstream_uri(api_ctx, 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
-
-
 -- whether nginx did more than decode the path or merge slashes in $uri,
 -- e.g. resolved a dot segment or cut the path at "#"
 local function uri_normalized_by_nginx(request_uri, uri)
@@ -946,17 +981,8 @@ function _M.http_access_phase()
     -- 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_uri = encoded_slash_match_uri(api_ctx.var.real_request_uri,
+                                              api_ctx.var.uri)
 
     local match_span = tracer.start(ngx_ctx, "http_router_match", 
tracer.kind.internal)
     if match_uri then
diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md
index 7fd60a602d..c1123fa0bd 100644
--- a/docs/en/latest/admin-api.md
+++ b/docs/en/latest/admin-api.md
@@ -1347,7 +1347,7 @@ For notes on ID syntax please refer to: [ID 
Syntax](#quick-note-on-id-syntax)
 | keys         | False    | An array of private keys | Private keys to pair 
with the `certs`. This field supports saving the value in Secret Manager using 
the [APISIX Secret](./terminology/secret.md) resource.                          
                                         |                                      
            |
 | client.ca    | False    | Certificate              | Sets the CA certificate 
that verifies the client. Requires OpenResty 1.19+.                             
       |                                                  |
 | client.depth | False    | Certificate              | Sets the verification 
depth in client certificate chains. Defaults to 1. Requires OpenResty 1.19+.    
         |                                                  |
-| client.skip_mtls_uri_regex | False    | An array of regular expressions, in 
PCRE format              | Used to match URI, if matched, this request bypasses 
the client certificate checking, i.e. skip the MTLS.             | 
["/hello[0-9]+", "/foobar"]                                                |
+| client.skip_mtls_uri_regex | False    | An array of regular expressions, in 
PCRE format              | Used to match URI, if matched, this request bypasses 
the client certificate checking, i.e. skip the MTLS. When 
`match_uri_encoded_slash` is enabled, a URI containing an encoded slash (`%2F`) 
must match in both its decoded and its `%2F`-kept form. | ["/hello[0-9]+", 
"/foobar"]                                                |
 | snis         | True, only if `type` is `server`     | Match Rules            
  | A non-empty array of HTTPS SNI                                              
                                   |                                            
      |
 | desc         | False    | Auxiliary                | Description of usage 
scenarios. | certs for production env |
 | labels       | False    | Match Rules              | Attributes of the 
resource specified as key-value pairs.                                          
             | {"version":"v2","build":"16","env":"production"} |
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md
index 0be2e59117..c44aa6cba0 100644
--- a/docs/zh/latest/admin-api.md
+++ b/docs/zh/latest/admin-api.md
@@ -1344,7 +1344,7 @@ SSL 资源请求地址:/apisix/admin/ssls/{id}
 | keys        | 否   | 私钥字符串数组 | `certs` 对应的证书私钥,需要与 `certs` 一一对应。该字段支持使用 
[APISIX Secret](./terminology/secret.md) 资源,将值保存在 Secret Manager 中。             
                                             |                                  
                |
 | client.ca   | 否   | 证书 |  设置将用于客户端证书校验的 `CA` 证书。该特性需要 OpenResty 为 1.19 
及以上版本。  |                                                  |
 | client.depth | 否   | 辅助 |  设置客户端证书校验的深度,默认为 1。该特性需要 OpenResty 为 1.19 及以上版本。 
|                                             |
-| client.skip_mtls_uri_regex | 否   | PCRE 正则表达式数组 |  用来匹配请求的 
URI,如果匹配,则该请求将绕过客户端证书的检查,也就是跳过 MTLS。 | ["/hello[0-9]+", "/foobar"]              
                              |
+| client.skip_mtls_uri_regex | 否   | PCRE 正则表达式数组 |  用来匹配请求的 
URI,如果匹配,则该请求将绕过客户端证书的检查,也就是跳过 MTLS。启用 `match_uri_encoded_slash` 
时,包含编码斜杠(`%2F`)的 URI 需要在解码形式和保留 `%2F` 的形式下都匹配。 | ["/hello[0-9]+", "/foobar"]    
                                        |
 | snis        | 是   | 匹配规则       | 非空数组形式,可以匹配多个 SNI。                          
                                               |                                
                  |
 | desc        | 否   | 辅助          | 证书描述。        | certs for production env    
                                           |
 | labels      | 否   | 匹配规则       | 标识附加属性的键值对。                                 
                                                  | 
{"version":"v2","build":"16","env":"production"} |
diff --git a/t/node/client-mtls-encoded-slash.t 
b/t/node/client-mtls-encoded-slash.t
new file mode 100644
index 0000000000..dedb9325b2
--- /dev/null
+++ b/t/node/client-mtls-encoded-slash.t
@@ -0,0 +1,174 @@
+#
+# 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;
+
+my $nginx_binary = $ENV{'TEST_NGINX_BINARY'} || 'nginx';
+my $version = eval { `$nginx_binary -V 2>&1` };
+
+if ($version !~ m/\/apisix-nginx-module/) {
+    plan(skip_all => "apisix-nginx-module not installed");
+} else {
+    plan('no_plan');
+}
+
+repeat_each(1);
+log_level('info');
+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);
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set ssl with skip_mtls_uri_regex and routes
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_ca_cert = t.read_file("t/certs/mtls_ca.crt")
+            local ssl_cert = t.read_file("t/certs/mtls_client.crt")
+            local ssl_key = t.read_file("t/certs/mtls_client.key")
+
+            local code, body = t.test('/apisix/admin/ssls/1',
+                ngx.HTTP_PUT,
+                json.encode({
+                    cert = ssl_cert,
+                    key = ssl_key,
+                    sni = "localhost",
+                    client = {
+                        ca = ssl_ca_cert,
+                        depth = 2,
+                        skip_mtls_uri_regex = {
+                            "^/public/status$",
+                            "^/open/.*",
+                        }
+                    }
+                })
+            )
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            -- /hello1 marks the exempt routes, /hello the cert-only one
+            local routes = {
+                {id = 1, uri = "/public/status", target = "/hello1"},
+                {id = 2, uri = "/open/:name", target = "/hello1"},
+                {id = 3, uri = "/:resource", target = "/hello"},
+            }
+            for _, r in ipairs(routes) do
+                local code, body = t.test('/apisix/admin/routes/' .. r.id,
+                    ngx.HTTP_PUT,
+                    json.encode({
+                        uri = r.uri,
+                        plugins = {
+                            ["proxy-rewrite"] = {uri = r.target},
+                        },
+                        upstream = {
+                            type = "roundrobin",
+                            nodes = {["127.0.0.1:1980"] = 1},
+                        },
+                    })
+                )
+                if code >= 300 then
+                    ngx.status = code
+                    ngx.say(body)
+                    return
+                end
+            end
+            ngx.say("done")
+        }
+    }
+--- request
+GET /t
+--- response_body
+done
+
+
+
+=== TEST 2: exempt uri skips the client cert
+--- exec
+curl -k https://localhost:1994/public/status
+--- response_body eval
+qr/hello1 world/
+
+
+
+=== TEST 3: encoded slash routed off the exempt route requires the client cert
+--- exec
+curl -k https://localhost:1994/public%2Fstatus
+--- response_body eval
+qr/400 Bad Request/
+--- error_log
+client certificate was not present
+
+
+
+=== TEST 4: lower case encoded slash requires the client cert
+--- exec
+curl -k https://localhost:1994/public%2fstatus
+--- response_body eval
+qr/400 Bad Request/
+--- error_log
+client certificate was not present
+
+
+
+=== TEST 5: encoded slash with a valid client cert
+--- exec
+curl --cert t/certs/mtls_client.crt --key t/certs/mtls_client.key -k 
https://localhost:1994/public%2Fstatus
+--- response_body eval
+qr/hello world/
+
+
+
+=== TEST 6: exempt pattern that covers the encoded form still skips the client 
cert
+--- exec
+curl -k https://localhost:1994/open/a%2Fb
+--- response_body eval
+qr/hello1 world/
+
+
+
+=== TEST 7: without match_uri_encoded_slash the encoded slash hits the exempt 
route
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri_with_parameter'
+--- exec
+curl -k https://localhost:1994/public%2Fstatus
+--- response_body eval
+qr/hello1 world/

Reply via email to