diff --git a/apisix/init.lua b/apisix/init.lua index aef5b7ebeb23..bf564452169b 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -59,6 +59,7 @@ local str_sub = string.sub local tonumber = tonumber local type = type local pairs = pairs +local ngx_re_match = ngx.re.match local control_api_router local is_http = false @@ -179,6 +180,7 @@ function _M.http_ssl_phase() local ok, err = router.router_ssl.match_and_set(api_ctx) + ngx_ctx.matched_ssl = api_ctx.matched_ssl core.tablepool.release("api_ctx", api_ctx) ngx_ctx.api_ctx = nil @@ -310,12 +312,38 @@ local function verify_tls_client(ctx) 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 + return true + end + end +end + + local function verify_https_client(ctx) local scheme = ctx.var.scheme if scheme ~= "https" then return true end + local matched_ssl = ngx.ctx.matched_ssl + 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 + local res = ctx.var.ssl_client_verify + if res ~= "SUCCESS" then + if res == "NONE" then + core.log.error("client certificate was not present") + else + core.log.error("client certificate verification is not passed: ", res) + end + + return false + end + end + local host = ctx.var.host local matched = router.router_ssl.match_and_set(ctx, true, host) if not matched then diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index bae273c96a17..d17e9f453773 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -767,6 +767,15 @@ _M.ssl = { minimum = 0, default = 1, }, + skip_mtls_uri_regex = { + type = "array", + minItems = 1, + uniqueItems = true, + items = { + description = "uri regular expression to skip mtls", + type = "string", + } + }, }, required = {"ca"}, }, diff --git a/apisix/ssl/router/radixtree_sni.lua b/apisix/ssl/router/radixtree_sni.lua index f88950be9ff2..b0e78a25fdc9 100644 --- a/apisix/ssl/router/radixtree_sni.lua +++ b/apisix/ssl/router/radixtree_sni.lua @@ -21,6 +21,7 @@ local apisix_ssl = require("apisix.ssl") local secret = require("apisix.secret") local ngx_ssl = require("ngx.ssl") local config_util = require("apisix.core.config_util") +local ngx = ngx local ipairs = ipairs local type = type local error = error @@ -229,7 +230,11 @@ function _M.match_and_set(api_ctx, match_only, alt_sni) return false, "failed to parse client cert: " .. err end - local ok, err = ngx_ssl.verify_client(parsed_cert, depth) + local reject_in_handshake = + (ngx.config.subsystem == "stream") or + (matched_ssl.value.client.skip_mtls_uri_regex == nil) + local ok, err = ngx_ssl.verify_client(parsed_cert, depth, + reject_in_handshake) if not ok then return false, err end diff --git a/docs/assets/images/skip-mtls.png b/docs/assets/images/skip-mtls.png new file mode 100644 index 000000000000..a739e66dfe74 Binary files /dev/null and b/docs/assets/images/skip-mtls.png differ diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index e2d7457b159d..367b8692b56c 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -1160,6 +1160,7 @@ SSL resource request address: /apisix/admin/ssls/{id} | keys | False | An array of private keys | Private keys to pair with the `certs`. | | | 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"] | | snis | True, only if `type` is `server` | Match Rules | A non-empty array of HTTPS SNI | | | labels | False | Match Rules | Attributes of the resource specified as key-value pairs. | {"version":"v2","build":"16","env":"production"} | | create_time | False | Auxiliary | Epoch timestamp (in seconds) of the created time. If missing, this field will be populated automatically. | 1602883670 | diff --git a/docs/en/latest/tutorials/client-to-apisix-mtls.md b/docs/en/latest/tutorials/client-to-apisix-mtls.md index a7ae43680f23..8c3b1c66c822 100644 --- a/docs/en/latest/tutorials/client-to-apisix-mtls.md +++ b/docs/en/latest/tutorials/client-to-apisix-mtls.md @@ -193,6 +193,131 @@ curl --resolve "test.com:9443:127.0.0.1" https://test.com:9443/anything -k --cer Since we configured the [proxy-rewrite](../plugins/proxy-rewrite.md) plugin in the example, we can see that the response body contains the request body received upstream, containing the correct data. +## MTLS bypass based on regular expression matching against URI + +APISIX allows configuring an URI whitelist to bypass MTLS. +If the URI of a request is in the whitelist, then the client certificate will not be checked. +Note that other URIs of the associated SNI will get HTTP 400 response +instead of alert error in the SSL handshake phase, if the client certificate is missing or invalid. + +### Timing diagram + +![skip mtls](../../../assets/images/skip-mtls.png) + +### Example + +1. Configure route and ssl via admin API + +```bash +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ +-H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/*", + "upstream": { + "nodes": { + "httpbin.org": 1 + } + } +}' + +curl http://127.0.0.1:9180/apisix/admin/ssls/1 \ +-H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "cert": "'"$( GET /uuid HTTP/2 +> Host: admin.apisix.dev:9443 +> user-agent: curl/7.68.0 +> accept: */* +> +* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): +* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): +* old SSL session ID is stale, removing +* Connection state changed (MAX_CONCURRENT_STREAMS == 128)! +< HTTP/2 400 +< date: Fri, 21 Apr 2023 07:53:23 GMT +< content-type: text/html; charset=utf-8 +< content-length: 229 +< server: APISIX/3.2.0 +< + +400 Bad Request + +

400 Bad Request

+
openresty
+

Powered by APISIX.

+ +* Connection #0 to host admin.apisix.dev left intact +``` + +3. Although the client certificate is missing, but the URI is in the whitelist, +you get successful response. + +```bash +curl https://admin.apisix.dev:9443/anything/foobar -i \ +--resolve 'admin.apisix.dev:9443:127.0.0.1' --cacert t/certs/mtls_ca.crt +HTTP/2 200 +content-type: application/json +content-length: 416 +date: Fri, 21 Apr 2023 07:58:28 GMT +access-control-allow-origin: * +access-control-allow-credentials: true +server: APISIX/3.2.0 +... +``` + ## Conclusion If you don't want to use curl or test on windows, you can read this gist for more details. [APISIX mTLS for client to APISIX](https://gist.github.com/bzp2010/6ce0bf7c15c191029ed54724547195b4). diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md index 74d008301c2f..f9e0ba3b70de 100644 --- a/docs/zh/latest/admin-api.md +++ b/docs/zh/latest/admin-api.md @@ -1168,6 +1168,7 @@ SSL 资源请求地址:/apisix/admin/ssls/{id} | keys | 否 | 私钥字符串数组 | `certs` 对应的证书私钥,需要与 `certs` 一一对应。 | | | client.ca | 否 | 证书 | 设置将用于客户端证书校验的 `CA` 证书。该特性需要 OpenResty 为 1.19 及以上版本。 | | | client.depth | 否 | 辅助 | 设置客户端证书校验的深度,默认为 1。该特性需要 OpenResty 为 1.19 及以上版本。 | | +| client.skip_mtls_uri_regex | 否 | PCRE 正则表达式数组 | 用来匹配请求的 URI,如果匹配,则该请求将绕过客户端证书的检查,也就是跳过 MTLS。 | ["/hello[0-9]+", "/foobar"] | | snis | 是 | 匹配规则 | 非空数组形式,可以匹配多个 SNI。 | | | labels | 否 | 匹配规则 | 标识附加属性的键值对。 | {"version":"v2","build":"16","env":"production"} | | create_time | 否 | 辅助 | epoch 时间戳,单位为秒。如果不指定则自动创建。 | 1602883670 | diff --git a/docs/zh/latest/tutorials/client-to-apisix-mtls.md b/docs/zh/latest/tutorials/client-to-apisix-mtls.md index e73010243d70..0999ab65a3db 100644 --- a/docs/zh/latest/tutorials/client-to-apisix-mtls.md +++ b/docs/zh/latest/tutorials/client-to-apisix-mtls.md @@ -193,6 +193,126 @@ curl --resolve "test.com:9443:127.0.0.1" https://test.com:9443/anything -k --cer 由于我们在示例中配置了 `proxy-rewrite` 插件,我们可以看到响应体中包含上游收到的请求体,包含了正确数据。 +## 基于对 URI 正则表达式匹配,绕过 MTLS + +APISIX 允许配置 URI 白名单以便绕过 MTLS。如果请求的 URI 在白名单内,客户端证书将不被检查。注意,如果针对白名单外的 URI 发请求,而该请求缺乏客户端证书或者提供了非法客户端证书,会得到 HTTP 400 响应,而不是在 SSL 握手阶段被拒绝。 + +### 时序图 + +![skip mtls](../../../assets/images/skip-mtls.png) + +### 例子 + +1. 配置路由和证书 + +```bash +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ +-H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/*", + "upstream": { + "nodes": { + "httpbin.org": 1 + } + } +}' + +curl http://127.0.0.1:9180/apisix/admin/ssls/1 \ +-H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "cert": "'"$( GET /uuid HTTP/2 +> Host: admin.apisix.dev:9443 +> user-agent: curl/7.68.0 +> accept: */* +> +* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): +* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): +* old SSL session ID is stale, removing +* Connection state changed (MAX_CONCURRENT_STREAMS == 128)! +< HTTP/2 400 +< date: Fri, 21 Apr 2023 07:53:23 GMT +< content-type: text/html; charset=utf-8 +< content-length: 229 +< server: APISIX/3.2.0 +< + +400 Bad Request + +

400 Bad Request

+
openresty
+

Powered by APISIX.

+ +* Connection #0 to host admin.apisix.dev left intact + + +3. 虽然没提供客户端证书,但是 URI 在白名单内,请求会被成功处理和响应。 + +```bash +curl https://admin.apisix.dev:9443/anything/foobar -i \ +--resolve 'admin.apisix.dev:9443:127.0.0.1' --cacert t/certs/mtls_ca.crt +HTTP/2 200 +content-type: application/json +content-length: 416 +date: Fri, 21 Apr 2023 07:58:28 GMT +access-control-allow-origin: * +access-control-allow-credentials: true +server: APISIX/3.2.0 +... +``` + ## 总结 想了解更多有关 Apache APISIX 的 mTLS 功能介绍,可以阅读:[TLS 双向认证](../mtls.md)。 diff --git a/t/node/client-mtls.t b/t/node/client-mtls.t index e3c6386934f3..4e1f16425feb 100644 --- a/t/node/client-mtls.t +++ b/t/node/client-mtls.t @@ -503,3 +503,118 @@ curl --cert t/certs/mtls_client.crt --key t/certs/mtls_client.key -k https://loc qr/400 Bad Request/ --- error_log client certificate verified with SNI localhost, but the host is test.com + + + +=== TEST 15: set verification (2 ssl objects, both have mTLS) +--- 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_ca_cert2 = t.read_file("t/certs/apisix.crt") + local ssl_cert = t.read_file("t/certs/mtls_client.crt") + local ssl_key = t.read_file("t/certs/mtls_client.key") + local data = { + upstream = { + type = "roundrobin", + nodes = { + ["127.0.0.1:1980"] = 1, + }, + }, + uri = "/*" + } + assert(t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + json.encode(data) + )) + + local data = { + cert = ssl_cert, + key = ssl_key, + sni = "localhost", + client = { + ca = ssl_ca_cert, + depth = 2, + skip_mtls_uri_regex = { + "/hello[0-9]+", + } + } + } + local code, body = t.test('/apisix/admin/ssls/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + return + end + + local data = { + cert = ssl_cert, + key = ssl_key, + sni = "test.com", + client = { + ca = ssl_ca_cert2, + depth = 2, + } + } + local code, body = t.test('/apisix/admin/ssls/2', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- request +GET /t + + + +=== TEST 16: skip the mtls, although no client cert provided +--- exec +curl -k https://localhost:1994/hello1 +--- response_body eval +qr/hello1 world/ + + + +=== TEST 17: skip the mtls, although with wrong client cert +--- exec +curl -k --cert t/certs/test2.crt --key t/certs/test2.key -k https://localhost:1994/hello1 +--- response_body eval +qr/hello1 world/ + + + +=== TEST 18: mtls failed, returns 400 +--- exec +curl -k https://localhost:1994/hello +--- response_body eval +qr/400 Bad Request/ +--- error_log +client certificate was not present + + + +=== TEST 19: mtls failed, wrong client cert +--- exec +curl --cert t/certs/test2.crt --key t/certs/test2.key -k https://localhost:1994/hello +--- response_body eval +qr/400 Bad Request/ +--- error_log +client certificate verification is not passed: FAILED:self signed certifica + + + +=== TEST 20: mtls failed, at handshake phase +--- exec +curl -k -v --resolve "test.com:1994:127.0.0.1" https://test.com:1994/hello +--- error_log +tls_process_client_certificate:peer did not return a certificate