diff --git a/changelog/69441.added.md b/changelog/69441.added.md new file mode 100644 index 000000000000..ad8cab185240 --- /dev/null +++ b/changelog/69441.added.md @@ -0,0 +1 @@ +Added `ssl_ca_certs` and `ssl_cert_reqs` options to `rest_cherrypy` for client-certificate validation on the TLS handshake (mutual TLS). When set, salt-api rejects connections without a peer cert signed by one of the configured trust anchors before any HTTP request is processed. diff --git a/salt/netapi/rest_cherrypy/__init__.py b/salt/netapi/rest_cherrypy/__init__.py index 4580c6dc81b9..accfb728ab84 100644 --- a/salt/netapi/rest_cherrypy/__init__.py +++ b/salt/netapi/rest_cherrypy/__init__.py @@ -96,10 +96,36 @@ def start(): verify_certs(apiopts["ssl_crt"], apiopts["ssl_key"]) + ssl_chain = apiopts.get("ssl_chain") + ssl_ca_certs = apiopts.get("ssl_ca_certs") + ssl_cert_reqs = apiopts.get("ssl_cert_reqs") + cherrypy.server.ssl_module = "builtin" cherrypy.server.ssl_certificate = apiopts["ssl_crt"] cherrypy.server.ssl_private_key = apiopts["ssl_key"] - if "ssl_chain" in apiopts.keys(): - cherrypy.server.ssl_certificate_chain = apiopts["ssl_chain"] + if ssl_chain: + cherrypy.server.ssl_certificate_chain = ssl_chain + + if ssl_ca_certs or ssl_cert_reqs: + # Client-cert validation + import ssl as _ssl + + _ctx = _ssl.create_default_context(_ssl.Purpose.CLIENT_AUTH) + _ctx.load_cert_chain( + certfile=apiopts["ssl_crt"], keyfile=apiopts["ssl_key"] + ) + if ssl_ca_certs: + _ctx.load_verify_locations(ssl_ca_certs) + if ssl_cert_reqs: + try: + _ctx.verify_mode = getattr(_ssl, ssl_cert_reqs) + except AttributeError: + logger.error( + "Invalid ssl_cert_reqs '%s'; expected one of " + "CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED", + ssl_cert_reqs, + ) + return None + cherrypy.server.ssl_context = _ctx cherrypy.quickstart(root, apiopts.get("root_prefix", "/"), conf) diff --git a/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py index 6bb68e043282..455e60f4203d 100644 --- a/salt/netapi/rest_cherrypy/app.py +++ b/salt/netapi/rest_cherrypy/app.py @@ -92,6 +92,26 @@ ssl_chain (Optional when using PyOpenSSL) the certificate chain to pass to ``Context.load_verify_locations``. + ssl_ca_certs + (Optional) Path to a file or directory of trust-anchor PEM + certificates used to verify clients. + + .. versionadded:: 3009.0 + + ssl_cert_reqs + (Optional) Peer-cert verification. One of ``CERT_NONE`` + (default; no client cert checked), ``CERT_OPTIONAL`` (verified + when presented), ``CERT_REQUIRED`` (handshake fails for + clients without a cert signed by a ``ssl_ca_certs``). + + .. versionadded:: 3009.0 + + ssl_allowed_cn + (Optional) List of allowed Subject CN values for accepted client + certificates. + + .. versionadded:: 3009.0 + disable_ssl A flag to disable SSL. Warning: your Salt authentication credentials will be sent in the clear! @@ -773,6 +793,24 @@ def salt_ip_verify_tool(): raise cherrypy.HTTPError(403, "Bad IP") +def salt_ssl_cn_filter_tool(): + """ + Optional CN-based filter on top of mTLS + """ + apiopts = cherrypy.config.get("apiopts") or {} + allowed = apiopts.get("ssl_allowed_cn") + if not allowed: + return + environ = cherrypy.request.wsgi_environ + cn = environ.get("SSL_CLIENT_S_DN_CN") + if not cn or cn not in allowed: + logger.error( + "ssl_allowed_cn: rejecting client with CN=%r (not in allow list)", + cn, + ) + raise cherrypy.HTTPError(403, "Client certificate CN not allowed") + + def salt_auth_tool(): """ Redirect all unauthenticated requests to the login page @@ -1141,6 +1179,7 @@ def lowdata_fmt(): ("lowdata_fmt", lowdata_fmt), ("hypermedia_out", hypermedia_out), ("salt_ip_verify", salt_ip_verify_tool), + ("salt_ssl_cn_filter", salt_ssl_cn_filter_tool), ], } @@ -1172,6 +1211,7 @@ class LowDataAdapter: "tools.hypermedia_in.on": True, "tools.lowdata_fmt.on": True, "tools.salt_ip_verify.on": True, + "tools.salt_ssl_cn_filter.on": True, } def __init__(self): diff --git a/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py b/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py new file mode 100644 index 000000000000..a1a480b397dc --- /dev/null +++ b/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py @@ -0,0 +1,62 @@ +""" +Unit tests for the ``salt_ssl_cn_filter`` rest_cherrypy tool (mTLS CN allow-list). +""" + +import pytest + +import salt.netapi.rest_cherrypy.app as cherrypy_app +from tests.support.mock import MagicMock, patch + + +class _HTTPError(Exception): + """cherrypy.HTTPError status code.""" + + def __init__(self, status, message=None): + self.status = status + self.message = message + super().__init__(message) + + +def _fake_cherrypy(apiopts, environ): + cp = MagicMock() + cp.config.get = MagicMock(side_effect=lambda key, default=None: apiopts) + cp.request.wsgi_environ = environ + cp.HTTPError = _HTTPError + return cp + + +@pytest.mark.parametrize("apiopts", [{}, {"ssl_allowed_cn": []}]) +def test_no_allow_list_is_noop(apiopts): + """no-op if empty""" + cp = _fake_cherrypy(apiopts, {"SSL_CLIENT_S_DN_CN": "anyone"}) + with patch.object(cherrypy_app, "cherrypy", cp): + assert cherrypy_app.salt_ssl_cn_filter_tool() is None + + +def test_allowed_cn_passes(): + cp = _fake_cherrypy( + {"ssl_allowed_cn": ["proxy.example.com", "master.example.com"]}, + {"SSL_CLIENT_S_DN_CN": "proxy.example.com"}, + ) + with patch.object(cherrypy_app, "cherrypy", cp): + assert cherrypy_app.salt_ssl_cn_filter_tool() is None + + +def test_disallowed_cn_rejected(): + cp = _fake_cherrypy( + {"ssl_allowed_cn": ["proxy.example.com"]}, + {"SSL_CLIENT_S_DN_CN": "attacker.example.com"}, + ) + with patch.object(cherrypy_app, "cherrypy", cp): + with pytest.raises(_HTTPError) as exc: + cherrypy_app.salt_ssl_cn_filter_tool() + assert exc.value.status == 403 + + +def test_missing_cn_rejected(): + """CA-signed but no CN fails when an allow-list is set.""" + cp = _fake_cherrypy({"ssl_allowed_cn": ["proxy.example.com"]}, {}) + with patch.object(cherrypy_app, "cherrypy", cp): + with pytest.raises(_HTTPError) as exc: + cherrypy_app.salt_ssl_cn_filter_tool() + assert exc.value.status == 403