Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/69441.added.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 28 additions & 2 deletions salt/netapi/rest_cherrypy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
40 changes: 40 additions & 0 deletions salt/netapi/rest_cherrypy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
],
}

Expand Down Expand Up @@ -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):
Expand Down
62 changes: 62 additions & 0 deletions tests/pytests/unit/netapi/cherrypy/test_ssl_cn_filter.py
Original file line number Diff line number Diff line change
@@ -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
Loading