diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 91f69289..16434412 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -11,8 +11,9 @@ jobs: matrix: # TODO: Add 3.7 to python-versions after GitHub action regression is resolved. # https://github.com/actions/setup-python/issues/682 + # TODO: switch macos-13 to macos-latest@arm64 python-version: ['3.8', '3.9', '3.10'] - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest, macos-13] steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 99d8cac1..e1fa20b9 100755 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,9 +11,10 @@ jobs: matrix: # TODO: Add 3.7 to python-versions after GitHub action regression is resolved. # https://github.com/actions/setup-python/issues/682 + # TODO: switch macos-13 to macos-latest@arm64 python-version: ['3.8', '3.9', '3.10'] - os: [ubuntu-latest, windows-latest, macos-latest] - + os: [ubuntu-latest, windows-latest, macos-13] + steps: - uses: actions/checkout@v1 diff --git a/.scrutinizer.yml b/.scrutinizer.yml index d72b4c65..508784b3 100755 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -10,6 +10,7 @@ build: tests: override: - command: 'pytest tests --cov=tabpy --cov-config=setup.cfg' + idle_timeout: 600 coverage: file: '.coverage' config_file: 'setup.cfg' @@ -26,7 +27,9 @@ build: tests: before: - pip install -r requirements.txt - override: [pytest] + override: + pytest: + idle_timeout: 600 checks: python: code_rating: true diff --git a/CHANGELOG b/CHANGELOG index 6cfda689..697fa8e4 100755 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,13 @@ # Changelog +## v2.10.0 + +### Improvements + +- Add TabPy parameter (TABPY_MINIMUM_TLS_VERSION) to specify the minimum TLS + version that the server will accept for secure connections. Default is + set to TLSv1_2. + ## v2.9.0 ### Improvements diff --git a/docs/server-config.md b/docs/server-config.md index 4b76b88d..c57219e5 100755 --- a/docs/server-config.md +++ b/docs/server-config.md @@ -8,7 +8,6 @@ * [Configuration File Content](#configuration-file-content) * [Configuration File Example](#configuration-file-example) - [Configuring HTTP vs HTTPS](#configuring-http-vs-https) -- [Configuring TPS](#configuring-http-vs-https) - [Authentication](#authentication) * [Enabling Authentication](#enabling-authentication) * [Password File](#password-file) @@ -83,6 +82,10 @@ at [`logging.config` documentation page](https://docs.python.org/3.6/library/log - `TABPY_KEY_FILE` - absolute path to private key file to run TabPy with. Only used with `TABPY_TRANSFER_PROTOCOL` set to `https`. Default value - not set. +- `TABPY_MINIMUM_TLS_VERSION` - set the minimum TLS version that the server + will accept for secure connections (`TLSv1_2`, `TLSv1_3`, etc). Refer to + [docs.python.org](https://docs.python.org/3/library/ssl.html#ssl.TLSVersion.MINIMUM_SUPPORTED) + for acceptable values. Default value - `TLSv1_2`. - `TABPY_LOG_DETAILS` - when set to `true` additional call information (caller IP, URL, client info, etc.) is logged. Default value - `false`. - `TABPY_MAX_REQUEST_SIZE_MB` - maximal request size supported by TabPy server @@ -124,6 +127,7 @@ settings._ # TABPY_TRANSFER_PROTOCOL = https # TABPY_CERTIFICATE_FILE = /path/to/certificate/file.crt # TABPY_KEY_FILE = /path/to/key/file.key +# TABPY_MINIMUM_TLS_VERSION = TLSv1_2 # Log additional request details including caller IP, full URL, client # end user info if provided. diff --git a/tabpy/VERSION b/tabpy/VERSION index c8e38b61..10c2c0c3 100755 --- a/tabpy/VERSION +++ b/tabpy/VERSION @@ -1 +1 @@ -2.9.0 +2.10.0 diff --git a/tabpy/tabpy_server/app/app.py b/tabpy/tabpy_server/app/app.py index 18b83391..421464b0 100644 --- a/tabpy/tabpy_server/app/app.py +++ b/tabpy/tabpy_server/app/app.py @@ -5,6 +5,7 @@ import os import shutil import signal +import ssl import sys import _thread @@ -83,6 +84,24 @@ def __init__(self, config_file, disable_auth_warning=True): self._parse_config(config_file) + def _initialize_ssl_context(self): + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + + ssl_context.load_cert_chain( + certfile=self.settings[SettingsParameters.CertificateFile], + keyfile=self.settings[SettingsParameters.KeyFile] + ) + + min_tls = self.settings[SettingsParameters.MinimumTLSVersion] + if not hasattr(ssl.TLSVersion, min_tls): + logger.warning(f"Unrecognized value for TABPY_MINIMUM_TLS_VERSION: {min_tls}") + min_tls = "TLSv1_2" + + logger.info(f"Setting minimum TLS version to {min_tls}") + ssl_context.minimum_version = ssl.TLSVersion[min_tls] + + return ssl_context + def _get_tls_certificates(self, config): tls_certificates = [] cert = config[SettingsParameters.CertificateFile] @@ -127,10 +146,7 @@ def run(self): protocol = self.settings[SettingsParameters.TransferProtocol] ssl_options = None if protocol == "https": - ssl_options = { - "certfile": self.settings[SettingsParameters.CertificateFile], - "keyfile": self.settings[SettingsParameters.KeyFile], - } + ssl_options = self._initialize_ssl_context() elif protocol != "http": msg = f"Unsupported transfer protocol {protocol}." logger.critical(msg) @@ -328,6 +344,8 @@ def _parse_config(self, config_file): (SettingsParameters.CertificateFile, ConfigParameters.TABPY_CERTIFICATE_FILE, None, None), (SettingsParameters.KeyFile, ConfigParameters.TABPY_KEY_FILE, None, None), + (SettingsParameters.MinimumTLSVersion, ConfigParameters.TABPY_MINIMUM_TLS_VERSION, + "TLSv1_2", None), (SettingsParameters.StateFilePath, ConfigParameters.TABPY_STATE_PATH, os.path.join(pkg_path, "tabpy_server"), None), (SettingsParameters.StaticPath, ConfigParameters.TABPY_STATIC_PATH, diff --git a/tabpy/tabpy_server/app/app_parameters.py b/tabpy/tabpy_server/app/app_parameters.py index c5c5ec12..3dab6c46 100644 --- a/tabpy/tabpy_server/app/app_parameters.py +++ b/tabpy/tabpy_server/app/app_parameters.py @@ -10,6 +10,7 @@ class ConfigParameters: TABPY_TRANSFER_PROTOCOL = "TABPY_TRANSFER_PROTOCOL" TABPY_CERTIFICATE_FILE = "TABPY_CERTIFICATE_FILE" TABPY_KEY_FILE = "TABPY_KEY_FILE" + TABPY_MINIMUM_TLS_VERSION = "TABPY_MINIMUM_TLS_VERSION" TABPY_LOG_DETAILS = "TABPY_LOG_DETAILS" TABPY_STATIC_PATH = "TABPY_STATIC_PATH" TABPY_MAX_REQUEST_SIZE_MB = "TABPY_MAX_REQUEST_SIZE_MB" @@ -33,6 +34,7 @@ class SettingsParameters: UploadDir = "upload_dir" CertificateFile = "certificate_file" KeyFile = "key_file" + MinimumTLSVersion = "minimum_tls_version" StateFilePath = "state_file_path" ApiVersions = "versions" LogRequestContext = "log_request_context" diff --git a/tabpy/tabpy_server/app/util.py b/tabpy/tabpy_server/app/util.py index 944b5997..d1bc8445 100644 --- a/tabpy/tabpy_server/app/util.py +++ b/tabpy/tabpy_server/app/util.py @@ -17,7 +17,7 @@ def validate_cert(cert_file_path): date_format, encoding = "%Y%m%d%H%M%SZ", "ascii" not_before = datetime.strptime(cert.get_notBefore().decode(encoding), date_format) not_after = datetime.strptime(cert.get_notAfter().decode(encoding), date_format) - now = datetime.now() + now = datetime.utcnow() https_error = "Error using HTTPS: " if now < not_before: diff --git a/tabpy/tabpy_server/common/default.conf b/tabpy/tabpy_server/common/default.conf index 971b0f6f..17ee5e3b 100644 --- a/tabpy/tabpy_server/common/default.conf +++ b/tabpy/tabpy_server/common/default.conf @@ -15,6 +15,7 @@ # TABPY_TRANSFER_PROTOCOL = https # TABPY_CERTIFICATE_FILE = /path/to/certificate/file.crt # TABPY_KEY_FILE = /path/to/key/file.key +# TABPY_MINIMUM_TLS_VERSION = TLSv1_2 # Log additional request details including caller IP, full URL, client # end user info if provided. diff --git a/tests/integration/integ_test_base.py b/tests/integration/integ_test_base.py index 330fbe84..d055d14e 100755 --- a/tests/integration/integ_test_base.py +++ b/tests/integration/integ_test_base.py @@ -225,7 +225,8 @@ def setUp(self): # Platform specific - for integration tests we want to engage # startup script - with open(self.tmp_dir + "/output.txt", "w") as outfile: + self.log_file_path = os.path.join(self.tmp_dir, "output.txt") + with open(self.log_file_path, "w") as outfile: cmd = ["tabpy", "--config=" + self.config_file_name, "--disable-auth-warning"] preexec_fn = None if platform.system() == "Windows": diff --git a/tests/integration/test_minimum_tls_version.py b/tests/integration/test_minimum_tls_version.py new file mode 100755 index 00000000..c32b3cc3 --- /dev/null +++ b/tests/integration/test_minimum_tls_version.py @@ -0,0 +1,54 @@ +from . import integ_test_base +import os + +class TestMinimumTLSVersion(integ_test_base.IntegTestBase): + def _get_log_contents(self): + with open(self.log_file_path, 'r') as f: + return f.read() + + def _get_config_file_name(self, tls_version: str) -> str: + config_file = open(os.path.join(self.tmp_dir, "test.conf"), "w+") + config_file.write( + "[TabPy]\n" + "TABPY_PORT = 9005\n" + "TABPY_TRANSFER_PROTOCOL = https\n" + "TABPY_CERTIFICATE_FILE = ./tests/integration/resources/2019_04_24_to_3018_08_25.crt\n" + "TABPY_KEY_FILE = ./tests/integration/resources/2019_04_24_to_3018_08_25.key\n" + ) + + if tls_version is not None: + config_file.write(f"TABPY_MINIMUM_TLS_VERSION = {tls_version}") + + pwd_file = self._get_pwd_file() + if pwd_file is not None: + pwd_file = os.path.abspath(pwd_file) + config_file.write(f"TABPY_PWD_FILE = {pwd_file}\n") + + config_file.close() + self.delete_config_file = True + return config_file.name + +class TestMinimumTLSVersionValid(TestMinimumTLSVersion): + def _get_config_file_name(self) -> str: + return super()._get_config_file_name("TLSv1_3") + + def test_minimum_tls_version_valid(self): + log_contents = self._get_log_contents() + self.assertIn("Setting minimum TLS version to TLSv1_3", log_contents) + +class TestMinimumTLSVersionInvalid(TestMinimumTLSVersion): + def _get_config_file_name(self) -> str: + return super()._get_config_file_name("TLSv-1.3") + + def test_minimum_tls_version_invalid(self): + log_contents = self._get_log_contents() + self.assertIn("Unrecognized value for TABPY_MINIMUM_TLS_VERSION", log_contents) + self.assertIn("Setting minimum TLS version to TLSv1_2", log_contents) + +class TestMinimumTLSVersionNotSpecified(TestMinimumTLSVersion): + def _get_config_file_name(self) -> str: + return super()._get_config_file_name(None) + + def test_minimum_tls_version_not_specified(self): + log_contents = self._get_log_contents() + self.assertIn("Setting minimum TLS version to TLSv1_2", log_contents) diff --git a/tests/unit/server_tests/test_config.py b/tests/unit/server_tests/test_config.py index 4090a7af..d36e7e99 100644 --- a/tests/unit/server_tests/test_config.py +++ b/tests/unit/server_tests/test_config.py @@ -209,6 +209,20 @@ def test_gzip_setting_off_valid( app = TabPyApp(self.config_file.name) self.assertEqual(app.settings["gzip_enabled"], False) + @patch("tabpy.tabpy_server.app.app.os.path.exists", return_value=True) + @patch("tabpy.tabpy_server.app.app._get_state_from_file") + @patch("tabpy.tabpy_server.app.app.TabPyState") + def test_min_tls_setting_valid( + self, mock_state, mock_get_state_from_file, mock_path_exists + ): + self.assertTrue(self.config_file is not None) + config_file = self.config_file + config_file.write("[TabPy]\n" "TABPY_MINIMUM_TLS_VERSION = TLSv1_3".encode()) + config_file.close() + + app = TabPyApp(self.config_file.name) + self.assertEqual(app.settings["minimum_tls_version"], "TLSv1_3") + class TestTransferProtocolValidation(unittest.TestCase): def assertTabPyAppRaisesRuntimeError(self, expected_message): with self.assertRaises(RuntimeError) as err: