Skip to content

Commit b301717

Browse files
Snow 1983343 add timeout for ocsp root certificates (snowflakedb#2559)
Co-authored-by: Mikołaj Kubik <mikolaj.kubik@snowflake.com>
1 parent a222548 commit b301717

File tree

7 files changed

+178
-59
lines changed

7 files changed

+178
-59
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1616
- Added an option to exclude `botocore` and `boto3` dependencies by setting `SNOWFLAKE_NO_BOTO` environment variable during installation
1717
- Revert changing exception type in case of token expired scenario for `Oauth` authenticator back to `DatabaseError`
1818
- Added support for pandas conversion for Day-time and Year-Month Interval types
19+
- Add `ocsp_root_certs_dict_lock_timeout` connection parameter to set the timeout (in seconds) for acquiring the lock on the OCSP root certs dictionary. Default value for this parameter is -1 which indicates no timeout.
1920

2021
- v3.17.4(September 22,2025)
2122
- Added support for intermediate certificates as roots when they are stored in the trust store

src/snowflake/connector/connection.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
_DOMAIN_NAME_MAP,
7171
_OAUTH_DEFAULT_SCOPE,
7272
ENV_VAR_PARTNER,
73+
OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT,
7374
PARAMETER_AUTOCOMMIT,
7475
PARAMETER_CLIENT_PREFETCH_THREADS,
7576
PARAMETER_CLIENT_REQUEST_MFA_TOKEN,
@@ -242,6 +243,10 @@ def _get_private_bytes_from_file(
242243
"internal_application_version": (CLIENT_VERSION, (type(None), str)),
243244
"disable_ocsp_checks": (False, bool),
244245
"ocsp_fail_open": (True, bool), # fail open on ocsp issues, default true
246+
"ocsp_root_certs_dict_lock_timeout": (
247+
OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT, # no timeout
248+
int,
249+
),
245250
"inject_client_pause": (0, int), # snowflake internal
246251
"session_parameters": (None, (type(None), dict)), # snowflake session parameters
247252
"autocommit": (None, (type(None), bool)), # snowflake
@@ -480,6 +485,7 @@ class SnowflakeConnection:
480485
validates the TLS certificate but doesn't check revocation status with OCSP provider.
481486
ocsp_fail_open: Whether or not the connection is in fail open mode. Fail open mode decides if TLS certificates
482487
continue to be validated. Revoked certificates are blocked. Any other exceptions are disregarded.
488+
ocsp_root_certs_dict_lock_timeout: Timeout for the OCSP root certs dict lock in seconds. Default value is -1, which means no timeout.
483489
session_id: The session ID of the connection.
484490
user: The user name used in the connection.
485491
host: The host name the connection attempts to connect to.

src/snowflake/connector/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ class FileHeader(NamedTuple):
354354

355355
HTTP_HEADER_VALUE_OCTET_STREAM = "application/octet-stream"
356356

357+
# OCSP
358+
OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT: int = -1
359+
357360

358361
@unique
359362
class OCSPMode(Enum):

src/snowflake/connector/network.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
HTTP_HEADER_CONTENT_TYPE,
4242
HTTP_HEADER_SERVICE_NAME,
4343
HTTP_HEADER_USER_AGENT,
44+
OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT,
4445
)
4546
from .crl import CRLConfig
4647
from .description import (
@@ -338,6 +339,12 @@ def __init__(
338339
ssl_wrap_socket.FEATURE_OCSP_RESPONSE_CACHE_FILE_NAME = (
339340
self._connection._ocsp_response_cache_filename if self._connection else None
340341
)
342+
# OCSP root timeout
343+
ssl_wrap_socket.FEATURE_ROOT_CERTS_DICT_LOCK_TIMEOUT = (
344+
self._connection._ocsp_root_certs_dict_lock_timeout
345+
if self._connection
346+
else OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT
347+
)
341348

342349
# CRL mode (should be DISABLED by default)
343350
ssl_wrap_socket.FEATURE_CRL_CONFIG = (

src/snowflake/connector/ocsp_snowflake.py

Lines changed: 73 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from . import constants
5959
from .backoff_policies import exponential_backoff
6060
from .cache import CacheEntry, SFDictCache, SFDictFileCache
61+
from .constants import OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT
6162
from .telemetry import TelemetryField, generate_telemetry_data_dict
6263
from .url_util import extract_top_level_domain_from_hostname, url_encode_str
6364
from .util_text import _base64_bytes_to_str
@@ -1037,6 +1038,7 @@ def __init__(
10371038
use_ocsp_cache_server=None,
10381039
use_post_method: bool = True,
10391040
use_fail_open: bool = True,
1041+
root_certs_dict_lock_timeout: int = OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT,
10401042
**kwargs,
10411043
) -> None:
10421044
self.test_mode = os.getenv("SF_OCSP_TEST_MODE", None)
@@ -1045,6 +1047,7 @@ def __init__(
10451047
logger.debug("WARNING - DRIVER CONFIGURED IN TEST MODE")
10461048

10471049
self._use_post_method = use_post_method
1050+
self._root_certs_dict_lock_timeout = root_certs_dict_lock_timeout
10481051
self.OCSP_CACHE_SERVER = OCSPServer(
10491052
top_level_domain=extract_top_level_domain_from_hostname(
10501053
kwargs.pop("hostname", None)
@@ -1415,67 +1418,79 @@ def _check_ocsp_response_cache_server(
14151418

14161419
def _lazy_read_ca_bundle(self) -> None:
14171420
"""Reads the local cabundle file and cache it in memory."""
1418-
with SnowflakeOCSP.ROOT_CERTIFICATES_DICT_LOCK:
1419-
if SnowflakeOCSP.ROOT_CERTIFICATES_DICT:
1420-
# return if already loaded
1421-
return
1422-
1421+
lock_acquired = SnowflakeOCSP.ROOT_CERTIFICATES_DICT_LOCK.acquire(
1422+
timeout=self._root_certs_dict_lock_timeout
1423+
)
1424+
if lock_acquired:
14231425
try:
1424-
ca_bundle = environ.get("REQUESTS_CA_BUNDLE") or environ.get(
1425-
"CURL_CA_BUNDLE"
1426-
)
1427-
if ca_bundle and path.exists(ca_bundle):
1428-
# if the user/application specifies cabundle.
1429-
self.read_cert_bundle(ca_bundle)
1430-
else:
1431-
import sys
1432-
1433-
# This import that depends on these libraries is to import certificates from them,
1434-
# we would like to have these as up to date as possible.
1435-
from requests import certs
1426+
if SnowflakeOCSP.ROOT_CERTIFICATES_DICT:
1427+
# return if already loaded
1428+
return
14361429

1437-
if (
1438-
hasattr(certs, "__file__")
1439-
and path.exists(certs.__file__)
1440-
and path.exists(
1441-
path.join(path.dirname(certs.__file__), "cacert.pem")
1442-
)
1443-
):
1444-
# if cacert.pem exists next to certs.py in request
1445-
# package.
1446-
ca_bundle = path.join(
1447-
path.dirname(certs.__file__), "cacert.pem"
1448-
)
1430+
try:
1431+
ca_bundle = environ.get("REQUESTS_CA_BUNDLE") or environ.get(
1432+
"CURL_CA_BUNDLE"
1433+
)
1434+
if ca_bundle and path.exists(ca_bundle):
1435+
# if the user/application specifies cabundle.
14491436
self.read_cert_bundle(ca_bundle)
1450-
elif hasattr(sys, "_MEIPASS"):
1451-
# if pyinstaller includes cacert.pem
1452-
cabundle_candidates = [
1453-
["botocore", "vendored", "requests", "cacert.pem"],
1454-
["requests", "cacert.pem"],
1455-
["cacert.pem"],
1456-
]
1457-
for filename in cabundle_candidates:
1458-
ca_bundle = path.join(sys._MEIPASS, *filename)
1459-
if path.exists(ca_bundle):
1460-
self.read_cert_bundle(ca_bundle)
1461-
break
1462-
else:
1463-
logger.error("No cabundle file is found in _MEIPASS")
1464-
try:
1465-
import certifi
1466-
1467-
self.read_cert_bundle(certifi.where())
1468-
except Exception:
1469-
logger.debug("no certifi is installed. ignored.")
1470-
1471-
except Exception as e:
1472-
logger.error("Failed to read ca_bundle: %s", e)
1473-
1474-
if not SnowflakeOCSP.ROOT_CERTIFICATES_DICT:
1475-
logger.error(
1476-
"No CA bundle file is found in the system. "
1477-
"Set REQUESTS_CA_BUNDLE to the file."
1478-
)
1437+
else:
1438+
import sys
1439+
1440+
# This import that depends on these libraries is to import certificates from them,
1441+
# we would like to have these as up to date as possible.
1442+
from requests import certs
1443+
1444+
if (
1445+
hasattr(certs, "__file__")
1446+
and path.exists(certs.__file__)
1447+
and path.exists(
1448+
path.join(path.dirname(certs.__file__), "cacert.pem")
1449+
)
1450+
):
1451+
# if cacert.pem exists next to certs.py in request
1452+
# package.
1453+
ca_bundle = path.join(
1454+
path.dirname(certs.__file__), "cacert.pem"
1455+
)
1456+
self.read_cert_bundle(ca_bundle)
1457+
elif hasattr(sys, "_MEIPASS"):
1458+
# if pyinstaller includes cacert.pem
1459+
cabundle_candidates = [
1460+
["botocore", "vendored", "requests", "cacert.pem"],
1461+
["requests", "cacert.pem"],
1462+
["cacert.pem"],
1463+
]
1464+
for filename in cabundle_candidates:
1465+
ca_bundle = path.join(sys._MEIPASS, *filename)
1466+
if path.exists(ca_bundle):
1467+
self.read_cert_bundle(ca_bundle)
1468+
break
1469+
else:
1470+
logger.error("No cabundle file is found in _MEIPASS")
1471+
try:
1472+
import certifi
1473+
1474+
self.read_cert_bundle(certifi.where())
1475+
except Exception:
1476+
logger.debug("no certifi is installed. ignored.")
1477+
1478+
except Exception as e:
1479+
logger.error("Failed to read ca_bundle: %s", e)
1480+
1481+
if not SnowflakeOCSP.ROOT_CERTIFICATES_DICT:
1482+
logger.error(
1483+
"No CA bundle file is found in the system. "
1484+
"Set REQUESTS_CA_BUNDLE to the file."
1485+
)
1486+
finally:
1487+
SnowflakeOCSP.ROOT_CERTIFICATES_DICT_LOCK.release()
1488+
else:
1489+
logger.info(
1490+
"Failed to acquire lock for ROOT_CERTIFICATES_DICT_LOCK. "
1491+
"Skipping reading CA bundle."
1492+
)
1493+
return
14791494

14801495
@staticmethod
14811496
def _calculate_tolerable_validity(this_update: float, next_update: float) -> int:

src/snowflake/connector/ssl_wrap_socket.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import certifi
2222
import OpenSSL.SSL
2323

24-
from .constants import OCSPMode
24+
from .constants import OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT, OCSPMode
2525
from .crl import CertRevocationCheckMode, CRLConfig, CRLValidator
2626
from .errorcode import ER_OCSP_RESPONSE_CERT_STATUS_REVOKED
2727
from .errors import OperationalError
@@ -32,6 +32,9 @@
3232

3333
DEFAULT_OCSP_MODE: OCSPMode = OCSPMode.FAIL_OPEN
3434
FEATURE_OCSP_MODE: OCSPMode = DEFAULT_OCSP_MODE
35+
FEATURE_ROOT_CERTS_DICT_LOCK_TIMEOUT: int = (
36+
OCSP_ROOT_CERTS_DICT_LOCK_TIMEOUT_DEFAULT_NO_TIMEOUT
37+
)
3538
DEFAULT_CRL_CONFIG: CRLConfig = CRLConfig()
3639
FEATURE_CRL_CONFIG: CRLConfig = DEFAULT_CRL_CONFIG
3740

@@ -210,6 +213,7 @@ def ssl_wrap_socket_with_cert_revocation_checks(
210213
ocsp_response_cache_uri=FEATURE_OCSP_RESPONSE_CACHE_FILE_NAME,
211214
use_fail_open=FEATURE_OCSP_MODE == OCSPMode.FAIL_OPEN,
212215
hostname=server_hostname,
216+
root_certs_dict_lock_timeout=FEATURE_ROOT_CERTS_DICT_LOCK_TIMEOUT,
213217
).validate(server_hostname, ret.connection)
214218
if not v:
215219
raise OperationalError(

test/integ/test_connection.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import warnings
1313
import weakref
1414
from unittest import mock
15+
from unittest.mock import MagicMock, PropertyMock, patch
1516
from uuid import uuid4
1617

1718
import pytest
@@ -1581,6 +1582,88 @@ def test_ocsp_mode_insecure_mode_and_disable_ocsp_checks_mismatch_ocsp_enabled(
15811582
assert "snowflake.connector.ocsp_snowflake" not in caplog.text
15821583

15831584

1585+
@pytest.mark.skipolddriver
1586+
def test_root_certs_dict_lock_timeout_fail_open(conn_cnx):
1587+
"""Test OCSP root certificates lock timeout with fail-open mode and side effect mock."""
1588+
1589+
override_config = {
1590+
"ocsp_fail_open": True,
1591+
"ocsp_root_certs_dict_lock_timeout": 0.1,
1592+
}
1593+
1594+
with patch(
1595+
"snowflake.connector.ocsp_snowflake.SnowflakeOCSP.ROOT_CERTIFICATES_DICT_LOCK"
1596+
) as mock_lock:
1597+
snowflake.connector.ocsp_snowflake.SnowflakeOCSP.ROOT_CERTIFICATES_DICT = {}
1598+
1599+
mock_lock.acquire = MagicMock(return_value=False)
1600+
mock_lock.release = MagicMock()
1601+
1602+
with conn_cnx(**override_config) as conn:
1603+
try:
1604+
with conn.cursor() as cur:
1605+
assert cur.execute("select 1").fetchall() == [(1,)]
1606+
1607+
if mock_lock.acquire.called:
1608+
mock_lock.acquire.assert_called_with(timeout=0.1)
1609+
assert conn._ocsp_root_certs_dict_lock_timeout == 0.1
1610+
finally:
1611+
conn.close()
1612+
1613+
1614+
@pytest.mark.skipolddriver
1615+
@pytest.mark.parametrize(
1616+
"ocsp_fail_open,timeout_value,expected_timeout",
1617+
[
1618+
(False, 1, 1), # fail-close mode with 1 second timeout
1619+
(True, 2, 2), # fail-open mode with 2 second timeout
1620+
],
1621+
)
1622+
def test_root_certs_dict_lock_timeout_with_property_mock(
1623+
conn_cnx, ocsp_fail_open, timeout_value, expected_timeout
1624+
):
1625+
"""Test OCSP root certificates lock timeout with property mock for different configurations."""
1626+
config = {
1627+
"ocsp_fail_open": ocsp_fail_open,
1628+
"ocsp_root_certs_dict_lock_timeout": timeout_value,
1629+
}
1630+
1631+
with patch(
1632+
"snowflake.connector.ocsp_snowflake.SnowflakeOCSP.ROOT_CERTIFICATES_DICT_LOCK"
1633+
) as mock_lock:
1634+
snowflake.connector.ocsp_snowflake.SnowflakeOCSP.ROOT_CERTIFICATES_DICT = {}
1635+
1636+
type(mock_lock).acquire = PropertyMock(return_value=lambda timeout: False)
1637+
type(mock_lock).release = PropertyMock(return_value=lambda: None)
1638+
1639+
with conn_cnx(**config) as conn:
1640+
with conn.cursor() as cur:
1641+
assert cur.execute("select 1").fetchall() == [(1,)]
1642+
1643+
assert conn._ocsp_root_certs_dict_lock_timeout == expected_timeout
1644+
conn.close()
1645+
1646+
1647+
@pytest.mark.skipolddriver
1648+
@pytest.mark.parametrize(
1649+
"config,expected_timeout",
1650+
[
1651+
({"ocsp_fail_open": True, "ocsp_root_certs_dict_lock_timeout": 0.001}, 0.001),
1652+
({"ocsp_fail_open": True}, -1), # no timeout specified, should default to -1
1653+
],
1654+
)
1655+
def test_root_certs_dict_lock_timeout_basic_config(conn_cnx, config, expected_timeout):
1656+
"""Test OCSP root certificates lock timeout basic configuration without mocking."""
1657+
with conn_cnx(**config) as conn:
1658+
try:
1659+
with conn.cursor() as cur:
1660+
assert cur.execute("select 1").fetchall() == [(1,)]
1661+
1662+
assert conn._ocsp_root_certs_dict_lock_timeout == expected_timeout
1663+
finally:
1664+
conn.close()
1665+
1666+
15841667
@pytest.mark.skipolddriver
15851668
def test_ocsp_mode_insecure_mode_deprecation_warning(conn_cnx):
15861669
with warnings.catch_warnings(record=True) as w:

0 commit comments

Comments
 (0)