From 6dfa67fe67dbb835e77d7e9fd0e85f65dcb77f27 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 21 Apr 2025 21:07:13 -0700 Subject: [PATCH 1/2] Add SSL option for connecting to Tableau Server with a weaker DH key length Fixes #1582 --- tableauserverclient/server/server.py | 40 +++++++++++++++ test/test_ssl_config.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/test_ssl_config.py diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..b756c7243 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("http://") and not server_address.startswith("https://"): @@ -313,3 +332,24 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning("WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily.") + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({'verify': self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if 'verify' in self._http_options: + del self._http_options['verify'] diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..000c31c83 --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,76 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch('requests.session') + @patch('tableauserverclient.server.endpoint.Endpoint.set_parameters') + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server('http://test') + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn('verify', self.server.http_options) + + @patch('ssl.create_default_context') + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options['verify'], mock_context) + + @patch('ssl.create_default_context') + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn('verify', self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn('verify', self.server.http_options) + + @patch('ssl.create_default_context') + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level='WARNING') as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any('WARNING: Allowing weak Diffie-Hellman keys' in record for record in log.output), + "Expected warning about weak DH keys was not logged" + ) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From b5577c2853e5d81037f9b70e07858a425a6b0e93 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 21 Apr 2025 21:16:38 -0700 Subject: [PATCH 2/2] Include correct black-formatted files --- tableauserverclient/server/server.py | 12 +++++---- test/test_ssl_config.py | 39 ++++++++++++++-------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b756c7243..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -335,7 +335,7 @@ def is_signed_in(self): def configure_ssl(self, *, allow_weak_dh=False): """Configure SSL/TLS settings for the server connection. - + Parameters ---------- allow_weak_dh : bool, optional @@ -343,13 +343,15 @@ def configure_ssl(self, *, allow_weak_dh=False): WARNING: This reduces security and should only be used as a temporary workaround. """ if allow_weak_dh: - logger.warning("WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily.") + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) self._ssl_context = ssl.create_default_context() # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) self._ssl_context.set_dh_parameters(min_key_bits=512) - self.add_http_options({'verify': self._ssl_context}) + self.add_http_options({"verify": self._ssl_context}) else: self._ssl_context = None # Remove any custom SSL context if we're reverting to default settings - if 'verify' in self._http_options: - del self._http_options['verify'] + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py index 000c31c83..036a326ca 100644 --- a/test/test_ssl_config.py +++ b/test/test_ssl_config.py @@ -7,27 +7,27 @@ class TestSSLConfig(unittest.TestCase): - @patch('requests.session') - @patch('tableauserverclient.server.endpoint.Endpoint.set_parameters') + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") def setUp(self, mock_set_parameters, mock_session): """Set up test fixtures with mocked session and request validation""" # Mock the session self.mock_session = MagicMock() mock_session.return_value = self.mock_session - + # Mock request preparation self.mock_request = MagicMock() self.mock_session.prepare_request.return_value = self.mock_request - + # Create server instance with mocked components - self.server = Server('http://test') + self.server = Server("http://test") def test_default_ssl_config(self): """Test that by default, no custom SSL context is used""" self.assertIsNone(self.server._ssl_context) - self.assertNotIn('verify', self.server.http_options) + self.assertNotIn("verify", self.server.http_options) - @patch('ssl.create_default_context') + @patch("ssl.create_default_context") def test_weak_dh_config(self, mock_create_context): """Test that weak DH keys can be allowed when configured""" # Setup mock SSL context @@ -36,15 +36,15 @@ def test_weak_dh_config(self, mock_create_context): # Configure SSL with weak DH self.server.configure_ssl(allow_weak_dh=True) - + # Verify SSL context was created and configured correctly mock_create_context.assert_called_once() mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) - + # Verify context was added to http options - self.assertEqual(self.server.http_options['verify'], mock_context) + self.assertEqual(self.server.http_options["verify"], mock_context) - @patch('ssl.create_default_context') + @patch("ssl.create_default_context") def test_disable_weak_dh_config(self, mock_create_context): """Test that SSL config can be reset to defaults""" # Setup mock SSL context @@ -54,23 +54,24 @@ def test_disable_weak_dh_config(self, mock_create_context): # First enable weak DH self.server.configure_ssl(allow_weak_dh=True) self.assertIsNotNone(self.server._ssl_context) - self.assertIn('verify', self.server.http_options) + self.assertIn("verify", self.server.http_options) # Then disable it self.server.configure_ssl(allow_weak_dh=False) self.assertIsNone(self.server._ssl_context) - self.assertNotIn('verify', self.server.http_options) + self.assertNotIn("verify", self.server.http_options) - @patch('ssl.create_default_context') + @patch("ssl.create_default_context") def test_warning_on_weak_dh(self, mock_create_context): """Test that a warning is logged when enabling weak DH keys""" logging.getLogger().setLevel(logging.WARNING) - with self.assertLogs(level='WARNING') as log: + with self.assertLogs(level="WARNING") as log: self.server.configure_ssl(allow_weak_dh=True) self.assertTrue( - any('WARNING: Allowing weak Diffie-Hellman keys' in record for record in log.output), - "Expected warning about weak DH keys was not logged" + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", ) -if __name__ == '__main__': - unittest.main() \ No newline at end of file + +if __name__ == "__main__": + unittest.main()