diff --git a/CHANGELOG.md b/CHANGELOG.md index 1288bff4f..4f69cb4d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added the `APICAST_PROXY_BUFFER_SIZE` variable to allow configuration of the buffer size for handling response from the proxied servers. [PR #1473](https://github.com/3scale/APIcast/pull/1473), [THREESCALE-8410](https://issues.redhat.com/browse/THREESCALE-8410) +- Added the `APICAST_HTTPS_VERIFY_CLIENT` variable to allow configuration of the `ssl_verify_client` directive. [PR #1491](https://github.com/3scale/APIcast/pull/1491) [THREESCALE-10156](https://issues.redhat.com/browse/THREESCALE-10156) + ## [3.15.0] 2024-04-04 ### Fixed diff --git a/doc/parameters.md b/doc/parameters.md index 083fa4df3..3565d9556 100644 --- a/doc/parameters.md +++ b/doc/parameters.md @@ -402,6 +402,15 @@ Path to a file with the X.509 certificate secret key in the PEM format. Defines the maximum length of the client certificate chain. If this parameter has `1` as its value, it is possible to include an additional certificate in the client certificate chain. For example, root certificate authority. +### `APICAST_HTTPS_VERIFY_CLIENT` + +**Default:** `optional_no_ca` +**Values:** +- `off`: Do not request client certificates or perform client certificate verification. +- `optional_no_ca`: Requests the client certificate, but does not fail the request when the client certificate is not signed by a trusted CA certificate. + +Enables verification of client certificates. You can verify client certificates TLS Client Certificate Validation policy. + ### `all_proxy`, `ALL_PROXY` **Default:** no value diff --git a/gateway/http.d/apicast.conf.liquid b/gateway/http.d/apicast.conf.liquid index 93d607757..3a76f9c09 100644 --- a/gateway/http.d/apicast.conf.liquid +++ b/gateway/http.d/apicast.conf.liquid @@ -110,7 +110,7 @@ server { {{ "conf/server.key" | filesystem | first }} {%- endif %}; - ssl_verify_client optional_no_ca; + ssl_verify_client {{ env.APICAST_HTTPS_VERIFY_CLIENT | default: "optional_no_ca" }}; ssl_certificate_by_lua_block { require('apicast.executor'):ssl_certificate() } ssl_verify_depth {{ env.APICAST_HTTPS_VERIFY_DEPTH | default: 1 }}; {%- endif %} diff --git a/gateway/src/apicast/policy/tls_validation/README.md b/gateway/src/apicast/policy/tls_validation/README.md index aee61667e..97adb15d2 100644 --- a/gateway/src/apicast/policy/tls_validation/README.md +++ b/gateway/src/apicast/policy/tls_validation/README.md @@ -5,3 +5,26 @@ This policy can validate TLS Client Certificate against a whitelist. Whitelist expects PEM formatted CA or Client certificates. It is not necessary to have the full certificate chain, just partial matches are allowed. For example you can add to the whitelist just leaf client certificates without the whole bundle with a CA certificate. + +## Configuration + +For this policy to work, APIcast need to be setup to listen for TLS connection. + +By default, during the TLS handshake, APIcast requests client certificates, but will not verify the certificate or terminate the request unless a TLS Validation Policy is in the chain. In most cases, the client not presenting a client certificate will not affect a service that does not have TLS Validation policy configured. The only exception is when a browser or front-end application uses the service. In this case, the browser will always prompt the user to choose a client certificate to send if they have any client certificates set up while accessing the service. + +To work around this, set the environment variable `APICAST_HTTPS_VERIFY_CLIENT` to `off`. This instructs APIcast to request a client certificate only when the policy is in the chain. + +NOTE: This policy is not compatible with `APICAST_PATH_ROUTING` or `APICAST_PATH_ROUTING_ONLY` when `APICAST_HTTPS_VERIFY_CLIENT` is set to `off`. + +## Example + +``` +{ + "name": "apicast.policy.tls_validation", + "configuration": { + "whitelist": [ + { "pem_certificate": ""-----BEGIN CERTIFICATE----- XXXXXX -----END CERTIFICATE-----"} + ] + } +} +``` diff --git a/gateway/src/apicast/policy/tls_validation/tls_validation.lua b/gateway/src/apicast/policy/tls_validation/tls_validation.lua index d9daff062..07a6c619d 100644 --- a/gateway/src/apicast/policy/tls_validation/tls_validation.lua +++ b/gateway/src/apicast/policy/tls_validation/tls_validation.lua @@ -4,6 +4,7 @@ local policy = require('apicast.policy') local _M = policy.new('tls_validation') local X509_STORE = require('resty.openssl.x509.store') local X509 = require('resty.openssl.x509') +local ngx_ssl = require "ngx.ssl" local ipairs = ipairs local tostring = tostring @@ -45,19 +46,40 @@ function _M.new(config) return self end +function _M:ssl_certificate() + -- Request client certificate + -- + -- We don't validate the certificate during the handshake, thus set `depth` to 0 (default is 1) + -- value here in order to save CPU cycles + -- + -- TODO: + -- provide ca_certs: See https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl.md#verify_client + -- handle verify_depth + -- + return ngx_ssl.verify_client() +end + function _M:access() local cert = X509.parse_pem_cert(ngx.var.ssl_client_raw_cert) + if not cert then + ngx.status = self.error_status + ngx.say("No required TLS certificate was sent") + return ngx.exit(ngx.status) + end + local store = self.x509_store - local ok, err = store:validate_cert(cert) + -- err is printed inside validate_cert method + -- so no need capture the err here + local ok, _ = store:validate_cert(cert) if not ok then ngx.status = self.error_status - ngx.say(err) + ngx.say("TLS certificate validation failed") return ngx.exit(ngx.status) end - return ok, err + return ok, nil end return _M diff --git a/gateway/src/resty/openssl/x509/store.lua b/gateway/src/resty/openssl/x509/store.lua index 4ebc6176f..6d14eb090 100644 --- a/gateway/src/resty/openssl/x509/store.lua +++ b/gateway/src/resty/openssl/x509/store.lua @@ -1,6 +1,7 @@ local base = require('resty.openssl.base') local X509_STORE_CTX = require('resty.openssl.x509.store.ctx') local ffi = require('ffi') +local ffi_gc = ffi.gc ffi.cdef([[ // https://www.openssl.org/docs/man1.1.0/crypto/X509_STORE_new.html @@ -45,7 +46,7 @@ local function X509_VERIFY_PARAM(flags) -- https://www.openssl.org/docs/man1.1.0/crypto/X509_VERIFY_PARAM_get_depth.html#example ffi_assert(C.X509_VERIFY_PARAM_set_flags(verify_param, flags)) - return ffi.gc(verify_param, C.X509_VERIFY_PARAM_free) + return ffi_gc(verify_param, C.X509_VERIFY_PARAM_free) end local _M = {} @@ -73,9 +74,8 @@ end function _M.new() local store = ffi_assert(C.X509_STORE_new()) + ffi_gc(store, C.X509_STORE_free) - -- @TODO cleanup here - -- ffi_gc(store, C.X509_STORE_free) -- enabling partial chains allows us to trust leaf certificates local verify_param = X509_VERIFY_PARAM(X509_V_FLAG_PARTIAL_CHAIN) diff --git a/spec/policy/tls_validation/tls_validation_spec.lua b/spec/policy/tls_validation/tls_validation_spec.lua index fd7f39f19..9dc438617 100644 --- a/spec/policy/tls_validation/tls_validation_spec.lua +++ b/spec/policy/tls_validation/tls_validation_spec.lua @@ -33,7 +33,7 @@ describe('tls_validation policy', function() policy:access() assert.stub(ngx.exit).was_called_with(400) - assert.stub(ngx.say).was_called_with('unable to get local issuer certificate') + assert.stub(ngx.say).was_called_with([[TLS certificate validation failed]]) end) it('rejects certificates that are not valid yet', function() @@ -43,7 +43,8 @@ describe('tls_validation policy', function() policy:access() - assert.stub(ngx.say).was_called_with('certificate is not yet valid') + assert.stub(ngx.exit).was_called_with(400) + assert.stub(ngx.say).was_called_with([[TLS certificate validation failed]]) end) it('rejects certificates that are not longer valid', function() @@ -53,7 +54,8 @@ describe('tls_validation policy', function() policy:access() - assert.stub(ngx.say).was_called_with([[certificate has expired]]) + assert.stub(ngx.exit).was_called_with(400) + assert.stub(ngx.say).was_called_with([[TLS certificate validation failed]]) end) it('accepts whitelisted certificate', function() diff --git a/t/apicast-policy-tls_validation.t b/t/apicast-policy-tls_validation.t index 4bb95b727..15b2c66ab 100644 --- a/t/apicast-policy-tls_validation.t +++ b/t/apicast-policy-tls_validation.t @@ -119,7 +119,7 @@ proxy_pass https://$server_addr:$apicast_port/t; proxy_set_header Host test; log_by_lua_block { collectgarbage() } --- response_body -unable to get local issuer certificate +TLS certificate validation failed --- error_code: 400 --- no_error_log [error] @@ -154,7 +154,7 @@ proxy_pass https://$server_addr:$apicast_port/t; proxy_set_header Host test; log_by_lua_block { collectgarbage() } --- response_body -Invalid certificate verification context +No required TLS certificate was sent --- error_code: 400 --- no_error_log [error] @@ -201,3 +201,155 @@ GET /t HTTP/1.0 --- no_error_log [error] --- user_files fixture=CA/files.pl eval + + + +=== TEST 6: TLS Client Certificate request client certificate when "APICAST_HTTPS_VERIFY_CLIENT: off" +and the policy is in the chain +--- configuration eval +use JSON qw(to_json); +use File::Slurp qw(read_file); + +to_json({ + services => [{ + proxy => { + hosts => ['test'], + policy_chain => [ + { name => 'apicast.policy.tls_validation', + configuration => { + whitelist => [ + { pem_certificate => CORE::join('', read_file('t/fixtures/CA/intermediate-ca.crt')) } + ] + } + }, + { name => 'apicast.policy.echo' }, + ] + } + }] +}); +--- test env +proxy_ssl_verify on; +proxy_ssl_trusted_certificate $TEST_NGINX_SERVER_ROOT/html/ca.crt; +proxy_ssl_certificate $TEST_NGINX_SERVER_ROOT/html/client.crt; +proxy_ssl_certificate_key $TEST_NGINX_SERVER_ROOT/html/client.key; +proxy_pass https://$server_addr:$apicast_port/t; +proxy_set_header Host test; +log_by_lua_block { collectgarbage() } +--- response_body +GET /t HTTP/1.0 +--- error_code: 200 +--- no_error_log +[error] +--- user_files fixture=CA/files.pl eval + + + +=== TEST 7: TLS Client Certificate request client certificate with path routing enabled +--- env eval +('APICAST_PATH_ROUTING' => '1') +--- configuration eval +use JSON qw(to_json); +use File::Slurp qw(read_file); + +to_json({ + services => [{ + id => 2, + backend_version => 1, + proxy => { + hosts => ['test'], + policy_chain => [ + { name => 'apicast.policy.tls_validation', + configuration => { + whitelist => [ + { pem_certificate => CORE::join('', read_file('t/fixtures/CA/intermediate-ca.crt')) } + ] + } + }, + { name => 'apicast.policy.echo' }, + ] + } + }, { + id => 3, + backend_version => 1, + proxy => { + hosts => ['test'], + policy_chain => [ + { name => 'apicast.policy.echo', configuration => { status => 404 }} + ] + } + }] +}); +--- test env +proxy_ssl_verify on; +proxy_ssl_trusted_certificate $TEST_NGINX_SERVER_ROOT/html/ca.crt; +proxy_ssl_certificate $TEST_NGINX_SERVER_ROOT/html/client.crt; +proxy_ssl_certificate_key $TEST_NGINX_SERVER_ROOT/html/client.key; +proxy_pass https://$server_addr:$apicast_port/t; +proxy_set_header Host test; +log_by_lua_block { collectgarbage() } +--- response_body +GET /t HTTP/1.0 +--- error_code: 200 +--- no_error_log +[error] +--- user_files fixture=CA/files.pl eval + + + +=== TEST 8: TLS Client Certificate request client certificate with "APICAST_HTTPS_VERIFY_CLIENT: off" +and path routing enabled +When path routing is enabled, APIcast will not able to select the correct service and build the +corresponding policy chain during the TLS handshake. It will then fallback to the setting defined by +`ssl_client_verify` and with `APICAST_HTTPS_VERIFY_CLIENT` is set to `off`, no client certificate will +be requested. +--- env eval +( + 'APICAST_PATH_ROUTING' => '1', + 'APICAST_HTTPS_VERIFY_CLIENT' => 'off' +) +--- configuration eval +use JSON qw(to_json); +use File::Slurp qw(read_file); + +to_json({ + services => [{ + id => 2, + backend_version => 1, + proxy => { + hosts => ['test'], + policy_chain => [ + { name => 'apicast.policy.tls_validation', + configuration => { + whitelist => [ + { pem_certificate => CORE::join('', read_file('t/fixtures/CA/intermediate-ca.crt')) } + ] + } + }, + { name => 'apicast.policy.echo' }, + ] + } + }, { + id => 3, + backend_version => 1, + proxy => { + hosts => ['test'], + policy_chain => [ + { name => 'apicast.policy.echo', configuration => { status => 404 }} + ] + } + }] +}); +--- test env +proxy_ssl_verify on; +proxy_ssl_trusted_certificate $TEST_NGINX_SERVER_ROOT/html/ca.crt; +proxy_ssl_certificate $TEST_NGINX_SERVER_ROOT/html/client.crt; +proxy_ssl_certificate_key $TEST_NGINX_SERVER_ROOT/html/client.key; +proxy_pass https://$server_addr:$apicast_port/t; +proxy_set_header Host test; +log_by_lua_block { collectgarbage() } +--- response_body +No required TLS certificate was sent +--- error_code: 400 +--- no_error_log +[error] +--- user_files fixture=CA/files.pl eval diff --git a/t/mutual-ssl.t b/t/mutual-ssl.t index caec8cab2..d12bb214e 100644 --- a/t/mutual-ssl.t +++ b/t/mutual-ssl.t @@ -4,8 +4,7 @@ use Test::APIcast::Blackbox 'no_plan'; env_to_apicast( 'APICAST_PROXY_HTTPS_CERTIFICATE' => "$Test::Nginx::Util::ServRoot/html/client.crt", 'APICAST_PROXY_HTTPS_CERTIFICATE_KEY' => "$Test::Nginx::Util::ServRoot/html/client.key", - 'APICAST_PROXY_HTTPS_PASSWORD_FILE' => "$Test::Nginx::Util::ServRoot/html/passwords.file", - 'APICAST_PROXY_HTTPS_SESSION_REUSE' => 'on', + 'APICAST_PROXY_HTTPS_SESSION_REUSE' => 'on' ); run_tests(); @@ -13,8 +12,11 @@ run_tests(); __DATA__ === TEST 1: Mutual SSL with password file ---- ssl random_port ---- configuration +--- env eval +( + 'APICAST_PROXY_HTTPS_PASSWORD_FILE' => "$Test::Nginx::Util::ServRoot/html/passwords.file" +) +--- configuration random_port env { "services": [ { @@ -59,3 +61,73 @@ ssl_client_i_dn: CN=localhost,OU=APIcast,O=3scale --- no_error_log [error] --- user_files fixture=mutual_ssl.pl eval + + + +=== TEST 2: Do not request client certificate when APICAST_HTTPS_VERIFY_CLIENT=off +--- env eval +( + 'APICAST_HTTPS_PORT' => "$Test::Nginx::Util::ServerPortForClient", + 'APICAST_HTTPS_CERTIFICATE' => "$Test::Nginx::Util::ServRoot/html/server.crt", + 'APICAST_HTTPS_CERTIFICATE_KEY' => "$Test::Nginx::Util::ServRoot/html/server.key", + 'APICAST_HTTPS_VERIFY_CLIENT' => "off", + 'BACKEND_ENDPOINT_OVERRIDE' => '' # disable override by Test::APIcast::Blackbox +) +--- backend random_port env + listen $TEST_NGINX_RANDOM_PORT; + location /transactions/oauth_authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } + + location /t { + content_by_lua_block { + print('client certificate subject: ', ngx.var.ssl_client_s_dn) + print('client certificate: ', ngx.var.ssl_client_raw_cert) + ngx.say(ngx.var.ssl_client_verify) + } + } +--- configuration random_port env +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "hosts": ["test"], + "api_backend": "http://test_backend:$TEST_NGINX_RANDOM_PORT/", + "backend": { + "endpoint": "http://test_backend:$TEST_NGINX_RANDOM_PORT/", + "host": "localhost" + }, + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- test env +proxy_ssl_verify on; +proxy_ssl_trusted_certificate $TEST_NGINX_SERVER_ROOT/html/ca.crt; +proxy_ssl_certificate $TEST_NGINX_SERVER_ROOT/html/client.crt; +proxy_ssl_certificate_key $TEST_NGINX_SERVER_ROOT/html/client.key; +proxy_pass https://$server_addr:$apicast_port/t?user_key=; +proxy_set_header Host test; +log_by_lua_block { collectgarbage() } +--- response_body +nil +--- error_log +client certificate subject: nil +client certificate: nil +--- no_error_log +[error] +[alert] +[crit] +--- user_files fixture=CA/files.pl eval