diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 2d402a0f..5ddba226 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -196,6 +196,8 @@ def _get_ssl_ctx( cafile: Optional[str] = None, capath: Optional[str] = None, client_auth_required: bool = False, + tls_min_version: ssl.TLSVersion = ssl.TLSVersion.MINIMUM_SUPPORTED, + tls_max_version: ssl.TLSVersion = ssl.TLSVersion.MAXIMUM_SUPPORTED ) -> ssl.SSLContext: """Load context supports SSL.""" ssl_cxt = ssl.SSLContext(protocol=protocol) @@ -227,6 +229,9 @@ def _get_ssl_ctx( raise exc_type(f"Cannot load server certificate file {certfile!r} or " f"its private key file {keyfile!r}: {msg}") + ssl_cxt.minimum_version = tls_min_version + ssl_cxt.maximum_version = tls_max_version + return ssl_cxt @@ -240,6 +245,8 @@ def start_wsgi_server( client_capath: Optional[str] = None, protocol: int = ssl.PROTOCOL_TLS_SERVER, client_auth_required: bool = False, + tls_min_version: ssl.TLSVersion = ssl.TLSVersion.MINIMUM_SUPPORTED, + tls_max_version: ssl.TLSVersion = ssl.TLSVersion.MAXIMUM_SUPPORTED ) -> Tuple[WSGIServer, threading.Thread]: """Starts a WSGI server for prometheus metrics as a daemon thread.""" @@ -250,7 +257,16 @@ class TmpServer(ThreadingWSGIServer): app = make_wsgi_app(registry) httpd = make_server(addr, port, app, TmpServer, handler_class=_SilentHandler) if certfile and keyfile: - context = _get_ssl_ctx(certfile, keyfile, protocol, client_cafile, client_capath, client_auth_required) + context = _get_ssl_ctx( + certfile, + keyfile, + protocol, + client_cafile, + client_capath, + client_auth_required, + tls_min_version, + tls_max_version + ) httpd.socket = context.wrap_socket(httpd.socket, server_side=True) t = threading.Thread(target=httpd.serve_forever) t.daemon = True diff --git a/tests/certs/client-cert.pem b/tests/certs/client-cert.pem new file mode 100644 index 00000000..5a054199 --- /dev/null +++ b/tests/certs/client-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICrzCCAZcCFCVu7nbOAxRNKBYa2cl22rdRCtvfMA0GCSqGSIb3DQEBCwUAMBIx +EDAOBgNVBAMMB1Rlc3QgQ0EwHhcNMjYwNTI2MTQyMTU0WhcNMzYwNTIzMTQyMTU0 +WjAWMRQwEgYDVQQDDAt0ZXN0LWNsaWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAJM2/f+8BBKjAlSF/9eiuB2444A2g6V007U5shZhBuPC9cNDxGKM +W1WT3QsgvxOdagdaANkpufqHcYixgFhx/v3lSEzlzd3uXyFMOiK7BdiPsctkqlWZ +VGIuUPpWwvJHWS4R5V1nYNCVsgyZB9XGThl7IknQzBK+tkY2GepqPQXyx1/AP7aB +AlTVBx3r7jTWvkrzvAdrcevrjhOOJUbPmgoiiEGSQeZSMvkdLERujvu5Y3wno2Mg +vcHJxCJwZ5y0RakmTzyAZLHke9lMavgt9F5yEA8G/8SnnXy6HrUp6B6I8Z1eLnof +b3mjUwiGxqDwEVBQHfMtOH6uC7ZE6zbNB1cCAwEAATANBgkqhkiG9w0BAQsFAAOC +AQEAJBchyhT2iyg42qi3uUE1NeCcEb/gM82LeihZbDd38ItUdU7TFqk7wEwsUNJk +k1uwNFVlyMGbHD1IvCAS4L8l/9uPaDG4DmLZ42shFRCaABNEFlKtGPa+YNuhFJ5z +DZKaLaJp8BKpvmoH+iPmsoCDlADwWmLgbdeFBGnHRuOnJBSmEEjQFrnz3jKrX6Lk ++IxVX5Rdp9xOKHBJkj99mgseEYZQk2YFFBCzHX7NNl6wBk/usKJoJeaOPhl9eOGK +VaUOfEdO5NuTRf9nPOORzqFtW3ErNjNjPjKN8VppHtXhRO6dWsmzGnmjVChxoZWC +H0rRJtGcab5HWf94laJilCj7Cw== +-----END CERTIFICATE----- diff --git a/tests/certs/client-key.pem b/tests/certs/client-key.pem new file mode 100644 index 00000000..e218a006 --- /dev/null +++ b/tests/certs/client-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCTNv3/vAQSowJU +hf/XorgduOOANoOldNO1ObIWYQbjwvXDQ8RijFtVk90LIL8TnWoHWgDZKbn6h3GI +sYBYcf795UhM5c3d7l8hTDoiuwXYj7HLZKpVmVRiLlD6VsLyR1kuEeVdZ2DQlbIM +mQfVxk4ZeyJJ0MwSvrZGNhnqaj0F8sdfwD+2gQJU1Qcd6+401r5K87wHa3Hr644T +jiVGz5oKIohBkkHmUjL5HSxEbo77uWN8J6NjIL3BycQicGectEWpJk88gGSx5HvZ +TGr4LfRechAPBv/Ep518uh61KegeiPGdXi56H295o1MIhsag8BFQUB3zLTh+rgu2 +ROs2zQdXAgMBAAECggEAATafUlJzkCRtelKJFiG+YGmr37HTVPOeY8HVe29noKH0 +kkbxNpoPOKiEK7l53wiu8oo7M+RZpucjOEFfnEWtmIchbkIoomR6vpSubVHa+FAl +jYEcvEw2u1ZuuW7Uotg+s8KsVXWVgTKdVJLq/cfpezaeGjtRK0hiH+MF71OFLD2I +UoszlVbTI9FAP+xwuFSJO4xyOirz2VmqgYvQd+qTuuPU2ZjPHFbBUXm6JDpchGJk +WdPp/7qEWKFwDufvgkA5rCFxwsiReQ9HfOS2f4l+7eg2uyjXAClYTt/lYq9PK1Ut +sk/R1Gq5C4S8G0f04Jk8J2bQKS57oRALfaJEps5LUQKBgQDPCPID4w9TnAhWoHtR +L5ps02KLi52sw9F3EVedVX2BjMM/jvRwtzg8I8iaWAE1iL0t8lDQRxbUcgNyWRvi +0/WG/2IESVlciqd4XuITLthj1PDIpIM2iCjQZpZKDqe9bVRPx/AY+UNV1aLSCEbF +xGS+uYoQRGpmiSYnRaQzIzgn0QKBgQC2CDOD1/1sEVFbfsWJTaAM3YjsQ1I5mXFI +HhoWpMKBUogWBXp9dzO4Ae/iRo0QviVUUY2bHlJjCoaQ0FzuiieZIhbOwHG2Qtf3 +JzmUaOSMecwsTeM05XHciwY+sWU/Udw7EzDhVpOHPZR31LKeapchUJGnofnOdkcY +zaHEwiuupwKBgQC6I0bD698Zws1UZRC6G1xxv1N4NtxaOewXawYktHoUgaQBftuS +g4gRufJfogPkR74ekx/JQkDqXF9w7WC+/OZgqzdKt0+afia3eEc2DAYNK6QYIKC/ +5IcdZz5z8t0o2CTXXeEl8uVxRJQQ1dQbdslFGLdijMBE08XzxQ8t0tpoIQKBgH09 +U0QovME3gQQ0SnBXKgDwAp6bCt16RshZfZWKshAL2nlcN5RPCRRWsNa7t56HVGOY +4JaS3BgsS70ivm2YO/pNy+df3FyLzM7M+/6x1F0aB3GL/QCNxDL6q8dCgeh4x88V +OxIuYL4xjg6MFoCL0YMoTa5J8PctxWi5Qc1/0lINAoGASyTZT8emfSDW0+kpqiYw +y+4ftFxqYPAVCf2IWGeQL8TrfkxUiJ7r4Pu5VK9nuYvMR/u4mvnJSG6F1NuJhxzY +4kUnoOnPhITLZjUvNE/xQEuhiJndiehZgSj0JAU6MqGa4pZOxZfqPAfhEF62b5wx +6Wlh7JxQAM+6agEfM3/OS3Y= +-----END PRIVATE KEY----- diff --git a/tests/certs/server-ca.pem b/tests/certs/server-ca.pem new file mode 100644 index 00000000..bbcada29 --- /dev/null +++ b/tests/certs/server-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUMrjGc/qUt+rpFb14OvBFePSMQRIwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNjA1MjYxNDIxNTNaFw0zNjA1MjMx +NDIxNTNaMBIxEDAOBgNVBAMMB1Rlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDFC2wPWOqoFHIYXASjL06yUnG0SlqLqw4oZphdj/q4pbyYPcva +QKI8m4u7Tq/l8JQmbd0sHqGMVYLmACX5ygkppzepz/bVgDeij7RztUgDjJwvUxAC +SEAss0dcE19P57j5ad24xmyV2iP0RK7oXnjapDrH1fhqvIyfybqRxt+50NODRh1t +z471240lDBPOG3ReRZ06dYEpzYaq3PQPatPJnaLGOmsf2NQ8sETTK35vcTMZrXsr +vzrftUCKn4DRyyZ58GE1VpevbVi8z/vHzWBYpRcHTZvfnOz12ijCd2wvnEtTu8TO ++GZS5j84KSF4AI7FlhDMPAS3/dhSLzXgnd4lAgMBAAGjUzBRMB0GA1UdDgQWBBRi +IztvE2ErRLmziv1XxxHCbism8DAfBgNVHSMEGDAWgBRiIztvE2ErRLmziv1XxxHC +bism8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAjBA50Oadg +Fx6d/cxzHCEd29BluqxjfzYc4TAeTP4NSQPWKXM7BgIqZbHDS/IAK/Xfd1raVCm5 +yv2v4Pe+0fTkezUk5uZjtgZoH5+o4aQL9GdbLO93F4rxzZhpoY92iaXAsoDEntRO +YyDnxLna6csiH4hyvr6Q8Yih/lDysw7DB1jozkFeZtX4ZsVFpsDnYLa5OQjJErpw +9GCM0NEzEW6HlqblsAuBv3DHavUAzfR4obD+Md60BRMxwC5Otl63sS99y81ycs2S +ffW0rLDtgB9hShCXBNeZkGsPrLwBr00nK7bvGaZwOU0Ysuxg+elP3cOXD2UcW7lc +Q+ZBinkQEFpB +-----END CERTIFICATE----- diff --git a/tests/certs/server-cert.pem b/tests/certs/server-cert.pem new file mode 100644 index 00000000..e1f11670 --- /dev/null +++ b/tests/certs/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIUJW7uds4DFE0oFhrZyXbat1EK294wDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNjA1MjYxNDIxNTNaFw0zNjA1MjMx +NDIxNTNaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAOl3Gnz9y+OHr3MIetuX7a/Bvo/lYkwTnmQpT4YC1ycalM0+ +M4wCewrbpn5RoNbH4j+oB6URuiLWlhk3SP9hOwbVbt4rOyCaulAVa5G0B46eqkG6 +ZDi9EWkt+L7Zvfwe+MbdG25xmRumkvc6iv8b+/0mM3t5cm3E8BHpwPi6kfoFmFHn +qREDfI4406tBkbSYUkRQL8qZvSWMo5HgIYmgrkKiWuZNV4bNYKHUOyaOqWvgn8En +ZxrlGt2ezHif/SFz+EaYJZkjBDJ5rMbwxl1BAVZKpSubKt5U59zXLkuachZF5sAY +sEd/LoZxF23/qP5y16C4mVzBYO/0z2udNOW9YAcCAwEAAaNeMFwwGgYDVR0RBBMw +EYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBSUEOHINPcDxtBsfNF5xSTUSqfF +vDAfBgNVHSMEGDAWgBRiIztvE2ErRLmziv1XxxHCbism8DANBgkqhkiG9w0BAQsF +AAOCAQEALxRf0TSusmJXO9pj5t3Njxc6VS+Ts/MnmE1NTloCCkVMEfYYzqROWHME +LOCg2YSnqX6S6Gwk3zjBSuT7aA4SNQ3lD9HndRYa5k+6/6qunnz5Q/g205GJ97us +HqkvdDjLE7lGmM5pIVjoyeMOWiQ6+EOtMt0CmL0nfqJ0DsDUZHVB7NB+MW20EVmC +XiXr52SuvKHDIms3QFkZWOi+scOKleQnvEVU7VqrQamKNtf8fxxGa3/AvjLLJ1ra +q9eB590eajBDdg50FttYLwyA/yb6cqrfIMfrHRj3R//yE2avtUkrKN6FgCtqgpoa +ZsIk6qEmFQWUTglyLwhk0f6m21FKAA== +-----END CERTIFICATE----- diff --git a/tests/certs/server-key.pem b/tests/certs/server-key.pem new file mode 100644 index 00000000..68f2b805 --- /dev/null +++ b/tests/certs/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDpdxp8/cvjh69z +CHrbl+2vwb6P5WJME55kKU+GAtcnGpTNPjOMAnsK26Z+UaDWx+I/qAelEboi1pYZ +N0j/YTsG1W7eKzsgmrpQFWuRtAeOnqpBumQ4vRFpLfi+2b38HvjG3RtucZkbppL3 +Oor/G/v9JjN7eXJtxPAR6cD4upH6BZhR56kRA3yOONOrQZG0mFJEUC/Kmb0ljKOR +4CGJoK5ColrmTVeGzWCh1Dsmjqlr4J/BJ2ca5Rrdnsx4n/0hc/hGmCWZIwQyeazG +8MZdQQFWSqUrmyreVOfc1y5LmnIWRebAGLBHfy6GcRdt/6j+cteguJlcwWDv9M9r +nTTlvWAHAgMBAAECggEAJ2/ZlxyOGPC+J+naSwbWfTZ2mLsQSDaWLmg2CTaonm/k +i+kCbxeqLjLdZIAoca+RHdyl8fHVJfZmo3rNx2nmvShHkprt4XuRll6P7axiDGrr +6q9wJ490hfZgiuigKZsXvgvyis0ApoWUVNPcT+yru978mlJxDG7UeMoqMTne18NQ +jKw8zTPrcDnSqxdzNs9hGcM/RYDAnNM1jFqnmJpJF9nTMf9gDOYQMJCb7yUZVOj/ +ccyc2AjTdp6corXZpSqiYHz4UwfZ0Wvf7BXwBAOxdYnM6qlZXc+RKZJYnlUbSNHZ +IRkIZXsRILAIgpL1fAhMAQodyFMjNU9wQAkva6j49QKBgQD8ohxWR3nKg9KXhSTU +7sv1E5WzUCEAX5gJTSRx9S8lAAotGEByaTO/pWcYtpo+jXukukERhDdnsxR7SJAf +7jOYLUQgqFgZXD/U7vaCNuYoRG0E1MwSqZnVIXOpZW2z6j0PwcbXLJVeLDpDctn1 +Ga7VvYiv7/KuvRSQfkSOrH8USwKBgQDsk5lLSOQb7Ke53gfHjeGMU4646JcNDFnD +hWtXQujABwQZmSDWudCvsLWwDr0O0kUDqDcPCEMhbNYo388DwowzHnE35Tzmzo5D +R/YZ+Mh+UuW+e5gLxdmn7Z1xENKft/4ceOkeBBExQumZYYsaNVA7znzotaPSjRfH +J36QHsG1tQKBgQC9NMBaUf/CD4ZaWqpiG1J/gyJ8AEgnGnEojjD8dC/R2zzD10T1 +KxtJrhwPozrUHGx8y83Ny6MfNDzjtE3UzDayAzzh5JLOs4tO84WFso4fnFe15ZXN +aF5BBGO2e7N0qrr+oRdFsitQM3mTaGIashiCFghYFDJCcnQDX74CyOgIDwKBgCht +JHHf99LpwtOZJF0uWo9/K9FfNYiuRpyJrQkRTvKZgFLbfugSgp2zJaj7K8Vfmxl/ +4kC4WbhZf9MmQ5rR4OFPX2t8ycZrH5ZRsrVHdQNZKRc+yYGhgosWqKPMiyFt8Idv +Be7yJPn1BDQInhuRZq+BnoipmV/+akTG8/Kuvs1NAoGALLN5lPRdZdTvjoiougt9 +MxqGfBR9H8PfAo/Eu8Et5Otln3P1Vl3SgeiwDGVb59avfBQ5N4UecTHMp/2jbxOw +w/AzvF9LMLtXKdyqnOeBfP2xgbEZ9chLeoePEYkATpQfgjs7qmzK+mwZin5EyjFa +tqn7AnX5AnDRtPIC10Z05rA= +-----END PRIVATE KEY----- diff --git a/tests/test_exposition.py b/tests/test_exposition.py index a3c97820..1885480f 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,9 +1,11 @@ import gzip from http.server import BaseHTTPRequestHandler, HTTPServer import os +import ssl import threading import time import unittest +import urllib import pytest @@ -16,7 +18,7 @@ from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp from prometheus_client.exposition import ( basic_auth_handler, choose_encoder, default_handler, MetricsHandler, - passthrough_redirect_handler, tls_auth_handler, + passthrough_redirect_handler, start_wsgi_server, tls_auth_handler, ) import prometheus_client.openmetrics.exposition as openmetrics @@ -633,6 +635,148 @@ def test_prom_no_version(self): self.assert_is_prom(exp) +class TestWsgiTLS(unittest.TestCase): + def setUp(self): + self.certs_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'certs' + ) + self.httpd = None + self.t = None + + def tearDown(self): + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() + self.t.join() + + def _assert_tls_connection( + self, + server_kwargs, + use_server_tls=True, + client_tls_kwargs=None, + request_tls_version=ssl.TLSVersion.TLSv1_3, + expect_exception=None + ): + self.httpd, self.t = start_wsgi_server(port=0, **server_kwargs) + port = self.httpd.server_address[1] + + if use_server_tls: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = request_tls_version + ctx.maximum_version = request_tls_version + ctx.load_verify_locations( + os.path.join(self.certs_dir, "server-ca.pem") + ) + + if client_tls_kwargs is not None: + ctx.load_cert_chain(**client_tls_kwargs) + + url = f"https://localhost:{port}/metrics" + else: + ctx = None + url = f"http://localhost:{port}/metrics" + + if expect_exception is not None: + self.assertRaises( + expect_exception, + urllib.request.urlopen, + url, + context=ctx + ) + else: + response = urllib.request.urlopen(url, context=ctx) + self.assertEqual(response.status, 200) + + def test_tls_disabled(self): + self._assert_tls_connection(server_kwargs={}, use_server_tls=False) + + def test_tls_enabled(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + } + self._assert_tls_connection(server_kwargs) + + def test_tls_untrusted_server_cert_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "cert.pem"), + "keyfile": os.path.join(self.certs_dir, "key.pem"), + } + self._assert_tls_connection( + server_kwargs, + expect_exception=urllib.error.URLError + ) + + def test_tls_versions_configured_correctly(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_min_version": ssl.TLSVersion.TLSv1_2, + "tls_max_version": ssl.TLSVersion.TLSv1_3, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_2 + ) + + def test_tls_using_lower_version_than_min_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_min_version": ssl.TLSVersion.TLSv1_3, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_2, + expect_exception=urllib.error.URLError + ) + + def test_tls_using_higher_version_than_max_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_max_version": ssl.TLSVersion.TLSv1_2, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_3, + expect_exception=urllib.error.URLError + ) + + def test_mtls_enabled(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "client_auth_required": True, + "client_cafile": os.path.join(self.certs_dir, "server-ca.pem"), + } + client_tls_kwargs = { + "certfile": os.path.join(self.certs_dir, "client-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "client-key.pem") + } + self._assert_tls_connection( + server_kwargs, + client_tls_kwargs=client_tls_kwargs + ) + + def test_mtls_untrusted_client_cert_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "client_auth_required": True, + "client_cafile": os.path.join(self.certs_dir, "server-cert.pem"), + } + client_tls_kwargs = { + "certfile": os.path.join(self.certs_dir, "cert.pem"), + "keyfile": os.path.join(self.certs_dir, "key.pem") + } + self._assert_tls_connection( + server_kwargs, + client_tls_kwargs=client_tls_kwargs, + expect_exception=ssl.SSLError + ) + + @pytest.mark.parametrize("scenario", [ { "name": "empty string",