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/