edgarrmondragon / citric

A client to the LimeSurvey Remote Control API 2, written in modern Python.
https://citric.rtfd.io
MIT License
26 stars 8 forks source link

[Bug]: SSL certificate verification failure #1033

Closed Robert-Vorster closed 10 months ago

Robert-Vorster commented 10 months ago

Citric Version

0.9.0

Python Version

3.8

LimeSurvey Version

5.1.9

Backend database

10.1.48-MariaDB-0ubuntu0.18.04.1

Operating System

Windows 10

Description

Trying to connect to on prem limesurvey to export survey responses.

I am able to import citric, but failing a client connection because of an SSL cert issue.

Normally the requests library allows us to pass the 'verify=False' parameter, but I don't see something similar for the client.

Error message received: SSLError: HTTPSConnectionPool(host='limesurvey.ccisouthafrica.com', port=443): Max retries exceeded with url: /index.php/admin/remotecontrol (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1131)')))

Again, this is a self hosted internal application with self signed certs to tick the boxes for security, risk and compliance.

Code

from citric import Client
remote_url = 'https://limesurvey.{company}.com/index.php/admin/remotecontrol'

client = Client(remote_url, "LS_API_Reader", "uFg$9Jb)CkZ6d83f[vhA4yE+rL{]VP(G")

---------------------------------------------------------------------------
SSLCertVerificationError                  Traceback (most recent call last)
File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connectionpool.py:467, in HTTPConnectionPool._make_request(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)
    466 try:
--> 467     self._validate_conn(conn)
    468 except (SocketTimeout, BaseSSLError) as e:

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connectionpool.py:1092, in HTTPSConnectionPool._validate_conn(self, conn)
   1091 if conn.is_closed:
-> 1092     conn.connect()
   1094 if not conn.is_verified:

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connection.py:642, in HTTPSConnection.connect(self)
    634     warnings.warn(
    635         (
    636             f"System time is way off (before {RECENT_DATE}). This will probably "
   (...)
    639         SystemTimeWarning,
    640     )
--> 642 sock_and_verified = _ssl_wrap_socket_and_match_hostname(
    643     sock=sock,
    644     cert_reqs=self.cert_reqs,
    645     ssl_version=self.ssl_version,
    646     ssl_minimum_version=self.ssl_minimum_version,
    647     ssl_maximum_version=self.ssl_maximum_version,
    648     ca_certs=self.ca_certs,
    649     ca_cert_dir=self.ca_cert_dir,
    650     ca_cert_data=self.ca_cert_data,
    651     cert_file=self.cert_file,
    652     key_file=self.key_file,
    653     key_password=self.key_password,
    654     server_hostname=server_hostname,
    655     ssl_context=self.ssl_context,
    656     tls_in_tls=tls_in_tls,
    657     assert_hostname=self.assert_hostname,
    658     assert_fingerprint=self.assert_fingerprint,
    659 )
    660 self.sock = sock_and_verified.socket

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connection.py:783, in _ssl_wrap_socket_and_match_hostname(sock, cert_reqs, ssl_version, ssl_minimum_version, ssl_maximum_version, cert_file, key_file, key_password, ca_certs, ca_cert_dir, ca_cert_data, assert_hostname, assert_fingerprint, server_hostname, ssl_context, tls_in_tls)
    781         server_hostname = normalized
--> 783 ssl_sock = ssl_wrap_socket(
    784     sock=sock,
    785     keyfile=key_file,
    786     certfile=cert_file,
    787     key_password=key_password,
    788     ca_certs=ca_certs,
    789     ca_cert_dir=ca_cert_dir,
    790     ca_cert_data=ca_cert_data,
    791     server_hostname=server_hostname,
    792     ssl_context=context,
    793     tls_in_tls=tls_in_tls,
    794 )
    796 try:

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\util\ssl_.py:469, in ssl_wrap_socket(sock, keyfile, certfile, cert_reqs, ca_certs, server_hostname, ssl_version, ciphers, ssl_context, ca_cert_dir, key_password, ca_cert_data, tls_in_tls)
    467     pass
--> 469 ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls, server_hostname)
    470 return ssl_sock

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\util\ssl_.py:513, in _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, server_hostname)
    511     return SSLTransport(sock, ssl_context, server_hostname)
--> 513 return ssl_context.wrap_socket(sock, server_hostname=server_hostname)

File ~\.conda\envs\PyETL\lib\ssl.py:500, in SSLContext.wrap_socket(self, sock, server_side, do_handshake_on_connect, suppress_ragged_eofs, server_hostname, session)
    494 def wrap_socket(self, sock, server_side=False,
    495                 do_handshake_on_connect=True,
    496                 suppress_ragged_eofs=True,
    497                 server_hostname=None, session=None):
    498     # SSLSocket class handles server_hostname encoding before it calls
    499     # ctx._wrap_socket()
--> 500     return self.sslsocket_class._create(
    501         sock=sock,
    502         server_side=server_side,
    503         do_handshake_on_connect=do_handshake_on_connect,
    504         suppress_ragged_eofs=suppress_ragged_eofs,
    505         server_hostname=server_hostname,
    506         context=self,
    507         session=session
    508     )

File ~\.conda\envs\PyETL\lib\ssl.py:1040, in SSLSocket._create(cls, sock, server_side, do_handshake_on_connect, suppress_ragged_eofs, server_hostname, context, session)
   1039             raise ValueError("do_handshake_on_connect should not be specified for non-blocking sockets")
-> 1040         self.do_handshake()
   1041 except (OSError, ValueError):

File ~\.conda\envs\PyETL\lib\ssl.py:1309, in SSLSocket.do_handshake(self, block)
   1308         self.settimeout(None)
-> 1309     self._sslobj.do_handshake()
   1310 finally:

SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1131)

During handling of the above exception, another exception occurred:

SSLError                                  Traceback (most recent call last)
File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connectionpool.py:790, in HTTPConnectionPool.urlopen(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)
    789 # Make the request on the HTTPConnection object
--> 790 response = self._make_request(
    791     conn,
    792     method,
    793     url,
    794     timeout=timeout_obj,
    795     body=body,
    796     headers=headers,
    797     chunked=chunked,
    798     retries=retries,
    799     response_conn=response_conn,
    800     preload_content=preload_content,
    801     decode_content=decode_content,
    802     **response_kw,
    803 )
    805 # Everything went great!

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connectionpool.py:491, in HTTPConnectionPool._make_request(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)
    490         new_e = _wrap_proxy_error(new_e, conn.proxy.scheme)
--> 491     raise new_e
    493 # conn.request() calls http.client.*.request, not the method in
    494 # urllib3.request. It also calls makefile (recv) on the socket.

SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1131)

The above exception was the direct cause of the following exception:

MaxRetryError                             Traceback (most recent call last)
File ~\.conda\envs\PyETL\lib\site-packages\requests\adapters.py:486, in HTTPAdapter.send(self, request, stream, timeout, verify, cert, proxies)
    485 try:
--> 486     resp = conn.urlopen(
    487         method=request.method,
    488         url=url,
    489         body=request.body,
    490         headers=request.headers,
    491         redirect=False,
    492         assert_same_host=False,
    493         preload_content=False,
    494         decode_content=False,
    495         retries=self.max_retries,
    496         timeout=timeout,
    497         chunked=chunked,
    498     )
    500 except (ProtocolError, OSError) as err:

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\connectionpool.py:844, in HTTPConnectionPool.urlopen(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)
    842     new_e = ProtocolError("Connection aborted.", new_e)
--> 844 retries = retries.increment(
    845     method, url, error=new_e, _pool=self, _stacktrace=sys.exc_info()[2]
    846 )
    847 retries.sleep()

File ~\.conda\envs\PyETL\lib\site-packages\urllib3\util\retry.py:515, in Retry.increment(self, method, url, response, error, _pool, _stacktrace)
    514     reason = error or ResponseError(cause)
--> 515     raise MaxRetryError(_pool, url, reason) from reason  # type: ignore[arg-type]
    517 log.debug("Incremented Retry for (url='%s'): %r", url, new_retry)

MaxRetryError: HTTPSConnectionPool(host='limesurvey.ccisouthafrica.com', port=443): Max retries exceeded with url: /index.php/admin/remotecontrol (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1131)')))

During handling of the above exception, another exception occurred:

SSLError                                  Traceback (most recent call last)
Input In [4], in <cell line: 1>()
----> 1 client = Client(remote_url, "LS_API_Reader", "uFg$9Jb)CkZ6d83f[vhA4yE+rL{]VP(G")

File ~\.conda\envs\PyETL\lib\site-packages\citric\client.py:126, in Client.__init__(self, url, username, password, requests_session, auth_plugin)
    116 def __init__(
    117     self,
    118     url: str,
   (...)
    123     auth_plugin: str = "Authdb",
    124 ) -> None:
    125     """Create a LimeSurvey Python API client."""
--> 126     self.__session = self.session_class(
    127         url,
    128         username,
    129         password,
    130         requests_session=requests_session or requests.session(),
    131         auth_plugin=auth_plugin,
    132     )

File ~\.conda\envs\PyETL\lib\site-packages\citric\session.py:116, in Session.__init__(self, url, username, password, auth_plugin, requests_session, json_encoder)
    113 self._session.headers.update(self._headers)
    114 self._encoder = json_encoder or json.JSONEncoder
--> 116 self.__key: str | None = self.get_session_key(
    117     username,
    118     password,
    119     auth_plugin,
    120 )
    122 self.__closed = False

File ~\.conda\envs\PyETL\lib\site-packages\citric\method.py:48, in Method.__call__(self, *params)
     35 def __call__(self, *params: t.Any) -> T:
     36     """Call RPC method.
     37 
     38     Args:
   (...)
     46     some_method 1 a
     47     """
---> 48     return self.__caller(self.__name, *params)

File ~\.conda\envs\PyETL\lib\site-packages\citric\session.py:151, in Session.rpc(self, method, *params)
    139 """Execute RPC method on LimeSurvey, with optional token authentication.
    140 
    141 Any method, except for `get_session_key`.
   (...)
    148     An RPC result.
    149 """
    150 if method == GET_SESSION_KEY or method.startswith("system."):
--> 151     return self._invoke(method, *params)
    153 # Methods requiring authentication
    154 return self._invoke(method, self.key, *params)

File ~\.conda\envs\PyETL\lib\site-packages\citric\session.py:184, in Session._invoke(self, method, *params)
    176 request_id = random.randint(1, 999_999)  # noqa: S311
    178 payload = {
    179     "method": method,
    180     "params": [*params],
    181     "id": request_id,
    182 }
--> 184 res = self._session.post(
    185     self.url,
    186     data=json.dumps(payload, cls=self._encoder),
    187     headers={
    188         "content-type": "application/json",
    189     },
    190 )
    191 res.raise_for_status()
    193 if not res.text:

File ~\.conda\envs\PyETL\lib\site-packages\requests\sessions.py:637, in Session.post(self, url, data, json, **kwargs)
    626 def post(self, url, data=None, json=None, **kwargs):
    627     r"""Sends a POST request. Returns :class:`Response` object.
    628 
    629     :param url: URL for the new :class:`Request` object.
   (...)
    634     :rtype: requests.Response
    635     """
--> 637     return self.request("POST", url, data=data, json=json, **kwargs)

File ~\.conda\envs\PyETL\lib\site-packages\requests\sessions.py:589, in Session.request(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)
    584 send_kwargs = {
    585     "timeout": timeout,
    586     "allow_redirects": allow_redirects,
    587 }
    588 send_kwargs.update(settings)
--> 589 resp = self.send(prep, **send_kwargs)
    591 return resp

File ~\.conda\envs\PyETL\lib\site-packages\requests\sessions.py:703, in Session.send(self, request, **kwargs)
    700 start = preferred_clock()
    702 # Send the request
--> 703 r = adapter.send(request, **kwargs)
    705 # Total elapsed time of the request (approximately)
    706 elapsed = preferred_clock() - start

File ~\.conda\envs\PyETL\lib\site-packages\requests\adapters.py:517, in HTTPAdapter.send(self, request, stream, timeout, verify, cert, proxies)
    513         raise ProxyError(e, request=request)
    515     if isinstance(e.reason, _SSLError):
    516         # This branch is for urllib3 v1.22 and later.
--> 517         raise SSLError(e, request=request)
    519     raise ConnectionError(e, request=request)
    521 except ClosedPoolError as e:

SSLError: HTTPSConnectionPool(host='limesurvey.ccisouthafrica.com', port=443): Max retries exceeded with url: /index.php/admin/remotecontrol (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1131)')))

Permissions Matrix

Screenshot 2023-11-16 132315

edgarrmondragon commented 10 months ago

Hi @Robert-Vorster, thanks for logging!

You could try using a custom session. For example:

import requests
from citric import Client

remote_url = 'https://limesurvey.{company}.com/index.php/admin/remotecontrol’
session = requests.Session()
session.verify = False

client = Client(
    remote_url,
    "LS_API_Reader”,
    "uFg$9Jb)CkZ6d83f[vhA4yE+rL{]VP(G”,
    requests_session=session,
)

Let me know if that works.

And of course you can point to a private certificate by setting REQUESTS_CA_BUNDLE=/path/to/your/certificate.pem in your environment, and the library will pick it up.

EDIT: I’ve documented this approach in https://citric.readthedocs.io/en/latest/how-to.html#change-the-default-http-session-attributes