cubewise-code / tm1py

TM1py is a Python package that wraps the TM1 REST API in a simple to use library.
http://tm1py.readthedocs.io/en/latest/
MIT License
190 stars 110 forks source link

TM1py ssl handshake error #1201

Open AndreyKadysh opened 1 day ago

AndreyKadysh commented 1 day ago

Hi!

During an internal security audit at our company, we faced false-positive security scan results for our IBM PA model ports. To fix this, we followed the IBM tech note https://www.ibm.com/docs/en/planning-analytics/2.0.0?topic=pa2f82-disable-des-3des-ciphers-in-planning-analytics-mitigate-false-positive-security-scans And added a list of cipher suites to the Admin Server parameter and to the model .cfg file.

After that sslv3 handshake errors began to appear in our python scripts:

requests.exceptions.SSLError: HTTPSConnectionPool(host='0.0.0.0.0', port=0000): Max retries exceeded with url: /api/v1/Configuration/ProductVersion/$value (Caused by SSLError(SSLError(1, '[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1000)')))

Although, аll the other services (PAW, Arc, PAX, http-calls via postman) work well.

We tried changing different sets of cipher suites but it didn't help at all. Also found that there is a way to change default ssl-context in requests lib. But we use TM1Service() for auth. And requests lib is wrapped around deeply into TM1Py library.

Environmental details: TM1 2.0.9.19 on premise , auth mode 5 Python 3.12.3 TM1Py 2.0.4 urllib3 2.2.3 requests 2.32.3

Sample python code for tests:

from TM1py.Services import TM1Service

address = "0.0.0.0"
port = 0000
namespace = ""
user = ""
password =""

# TM1 Connection
with (TM1Service(address=address, port=port, ssl=True, user=user, password=password, namespace=namespace )) as tm1:
    server_name = tm1.server.get_server_name()
    print(server_name)

P.S. We accidentally figured out that it works well with python 3.9.7 But we cannot go back to the previous version of python for a number of reasons.

Any help would be appreciated! Andre

macsir commented 21 hours ago

I am seeing the same issue from Python 3.12 and 3.9 works fine.

MariusWirtz commented 18 hours ago

I can't reproduce this issue. I included the tlsCipherList in the tm1s.cfg and run below script with the latest release of python 3.12 and TM1py.

Am I missing something?

from TM1py import TM1Service

tm1_params = {
    "address": "localhost",
    "port": 12354,
    "user": "admin",
    "password": "apple",
    "ssl": True,
}
with TM1Service(**tm1_params) as tm1:
    print(tm1.server.get_product_version())
    print(tm1.server.get_server_name())

@AndreyKadysh, @macsir
can you try to manipulate the ssl_context directly in your local TM1py RestService? Possibly somewhere around here, I guess? https://github.com/cubewise-code/tm1py/blob/59a93a8ff9b2e5053cbe60274a67969e42c77e28/TM1py/Services/RestService.py#L462 If this works, we could do an enhancement to allow users to pass a custom ssl_context in the TM1Service constructor

AndreyKadysh commented 15 hours ago

Hi Marius! Thank you for reply!

Have you done also changes to admin server parameter called "Supported Cipher Suites" in Cognos Configuration? It can be a reason why you can't reproduce issue.

Our detailed traceback:

Traceback (most recent call last):
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\ssl_test_2.py", line 11, in <module>
    with (TM1Service(address=address, port=port, ssl=True, user=user, password=password, namespace=namespace )) as tm1:
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\TM1py\Services\TM1Service.py", line 64, in __init__
    self._tm1_rest = RestService(**kwargs)
                     ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\TM1py\Services\RestService.py", line 183, in __init__
    self.connect()
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\TM1py\Services\RestService.py", line 332, in connect
    self._start_session(
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\TM1py\Services\RestService.py", line 745, in _start_session
    response = self._s.get(
               ^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\requests\sessions.py", line 602, in get
    return self.request("GET", url, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\requests\sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\requests\sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\akadysh\PycharmProjects\ibm_connections\.venv\Lib\site-packages\requests\adapters.py", line 698, in send
    raise SSLError(e, request=request)
requests.exceptions.SSLError: HTTPSConnectionPool(host='0.0.0.0', port=0000): Max retries exceeded with url: /api/v1/Configuration/ProductVersion/$value (Caused by SSLError(SSLError(1, '[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1000)')))

So the error appears in RestService method _start_session() here: https://github.com/cubewise-code/tm1py/blob/59a93a8ff9b2e5053cbe60274a67969e42c77e28/TM1py/Services/RestService.py#L763

Tried to tweak http adapter this way, but it doen't help (maybe did something wrong)

def _manage_http_adapter(self):
    # Create a custom SSL context
    ssl_context = ssl.create_default_context()

    # Disable SSLv3
    ssl_context.options |= ssl.OP_NO_SSLv3

    adapter = HTTPAdapterWithSocketOptions(
        pool_connections=int(self._connection_pool_size or self.DEFAULT_CONNECTION_POOL_SIZE),
        pool_maxsize=int(self._connection_pool_size),
        ssl_context=ssl_context  # Pass the custom SSL context here
    )

    self._s.mount(self._base_url, adapter)
Cubewise-JoeCHK commented 4 hours ago

I met this issue before. in my case, the SSL is using TLSv1 (not familiar with it... just look at the result of openssl...). please make sure your SSL configuration with openssl or other tools

then, may check the following changes what I made within TM1py source code

BUT!!!!! some of the changes is not a MUST... because TM1 version is v10.xxx

SSL Configuration

# in TM1py.Services.Utils.Utils
import ssl 

class HTTPAdapterWithSocketOptions(HTTPAdapter):
    def __init__(self, *args, **kwargs):
        self.socket_options = kwargs.pop("socket_options", None)
++++++  context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
++++++  context.options &= ~ssl.OP_NO_SSLv3
++++++  ciphers = ":".join(['@SECLEVEL=0', 'ALL'])
++++++  context.set_ciphers(ciphers)
++++++  self.ssl_context = context
        super(HTTPAdapterWithSocketOptions, self).__init__(*args, **kwargs)

    def init_poolmanager(self, *args, **kwargs):
        # must use hasattr here, as socket_options may be not-set in case TM1Service was created with restore_from_file
        if hasattr(self, "socket_options"):
            kwargs["socket_options"] = self.socket_options
++++++  kwargs['ssl_context'] = self.ssl_context
        super(HTTPAdapterWithSocketOptions, self).init_poolmanager(*args, **kwargs)

Mount SSL Context to HTTPAdapter

Since the TM1 version is v10, TM1py may face compatibility issues in communication between the client and server.

def _manage_http_adapter(self):
    adapter = HTTPAdapterWithSocketOptions(
        pool_connections=int(self._connection_pool_size or self.DEFAULT_CONNECTION_POOL_SIZE),
        pool_maxsize=int(self._connection_pool_size))
+++ self._s.mount('https://', adpater)
    self._s.mount(self._base_url, adapter)

Adjust the HTTP handler logic

During TM1Service.__init__, the requests.session may perform a health check for the connection without any adapter mounted, which may lead to SSL errors.*

class RestService():
  def __init__():
    ...
    self._version = None
    self._headers = self.HEADERS.copy()
    if "session_context" in kwargs:
        self._headers["TM1-SessionContext"] = kwargs["session_context"]

    self.disable_http_warnings()

    self._s = Session()
+++ self._manage_http_adapter()    
    if self._proxies:
        self._s.proxies = self._proxies

    # First contact with TM1
    self.connect()
    if not self._version:
        self.set_version()

--- self._manage_http_adapter()

Logout Entrypoint (Maybe no need to change)

def logout(self, timeout: float = None, **kwargs):
    """ End TM1 Session and HTTP session
    """

    try:
-----   self.POST('/ActiveSession/tm1.Close', '', headers={"Connection": "close"}, timeout=timeout,
                  async_requests_mode=False, **kwargs)
+++++   self._s.post('https://localhost:8000/api/logout', '', headers={"Connection": "close"}, timeout=timeout,
                  async_requests_mode=False, verify=False, **kwargs)
    finally:
        self._s.close()
macsir commented 2 hours ago

@MariusWirtz What value do you use in tlsCipherList? at least adding these values doesn't work for me. Still same error. tlsCipherList=TLS_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_256_CBC_SHA256,TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384

And looks like it always came from this Delete operation and Post is fine.

  File "/Users/.local/lib/python3.12/site-packages/TM1py/Services/RestService.py", line 594, in cancel_async_operation
    response = self._s.delete(url, **kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.local/lib/python3.12/site-packages/requests/sessions.py", line 671, in delete
    return self.request("DELETE", url, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^