diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 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,26 @@ 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..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +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()