From bede60a42366ee2d113bf2c24ac80a298827f8fe Mon Sep 17 00:00:00 2001 From: Stefan Bogner Date: Fri, 12 Jun 2026 20:25:17 +0200 Subject: [PATCH 1/2] add mTLS for the salt api --- changelog/api-mtls.added.md | 1 + salt/netapi/rest_cherrypy/__init__.py | 30 ++++++++- salt/netapi/rest_cherrypy/app.py | 40 ++++++++++++ .../netapi/cherrypy/test_ssl_cn_filter.py | 61 +++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 changelog/api-mtls.added.md create mode 100644 tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py diff --git a/changelog/api-mtls.added.md b/changelog/api-mtls.added.md new file mode 100644 index 000000000000..ad8cab185240 --- /dev/null +++ b/changelog/api-mtls.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..8dc75253bc99 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..6c7c4f9c633d --- /dev/null +++ b/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py @@ -0,0 +1,61 @@ +""" +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 From f30a3e727db95ab2ce7550113ba24b6903259a40 Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Fri, 12 Jun 2026 11:46:24 -0700 Subject: [PATCH 2/2] Fix pre-commit failures on api-mtls PR - Rename changelog/api-mtls.added.md to changelog/69441.added.md to match the PR number naming convention enforced by the Check Changelog Entries hook. - Strip trailing whitespace from the ssl_allowed_cn docstring in salt/netapi/rest_cherrypy/app.py. - Add blank line after module docstring in tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py per black formatting. Co-authored-by: Stefan Bogner --- changelog/{api-mtls.added.md => 69441.added.md} | 0 salt/netapi/rest_cherrypy/app.py | 2 +- tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename changelog/{api-mtls.added.md => 69441.added.md} (100%) diff --git a/changelog/api-mtls.added.md b/changelog/69441.added.md similarity index 100% rename from changelog/api-mtls.added.md rename to changelog/69441.added.md diff --git a/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py index 8dc75253bc99..455e60f4203d 100644 --- a/salt/netapi/rest_cherrypy/app.py +++ b/salt/netapi/rest_cherrypy/app.py @@ -108,7 +108,7 @@ ssl_allowed_cn (Optional) List of allowed Subject CN values for accepted client - certificates. + certificates. .. versionadded:: 3009.0 diff --git a/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py b/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py index 6c7c4f9c633d..a1a480b397dc 100644 --- a/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py +++ b/tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py @@ -1,6 +1,7 @@ """ 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