snowflakedb / snowflakedb/snowflake-connector-python
SNOW-3824928: REQUESTS_CA_BUNDLE / SSL_CERT_FILE ignored — OCSP/CRL SSL wrapper defaults ca_certs to certifi.where() before resolving env vars
@sfc-gh-snow-drivers-warsaw-dl is already working on this.
Since Jul 28, 2026.
- Dominant language
- Python
- Stars
- 730
- Forks
- 574
- Avg merge
- 5h 45m
- Merged PRs (30d)
- 16
Description
Python version
Python 3.12.10
Operating system and processor architecture
macOS-26.5.1-arm64-arm-64bit
Installed packages
snowflake-connector-python 4.7.1
What did you do?
Summary
The injected OCSP/CRL SSL wrapper resolves its CA bundle in an order that makes REQUESTS_CA_BUNDLE and SSL_CERT_FILE unreachable. _resolve_cafile() documents a precedence of ca_certs kwarg → REQUESTS_CA_BUNDLE → SSL_CERT_FILE, but the caller sets ca_certs to certifi.where() before calling it, so the environment variables are never consulted. As a result the connector trusts only certifi's public roots, regardless of the env vars.
This breaks any environment where TLS must be verified against a custom/private CA — e.g. behind a TLS-inspecting proxy. Setting REQUESTS_CA_BUNDLE/SSL_CERT_FILE (the documented mechanism) has no effect.
Environment
- snowflake-connector-python 4.7.1
- Python 3.12
- OS: macOS (also reproducible anywhere a private/inspecting CA is required)
The code path
inject_into_urllib3() is called unconditionally at import (snowflake/connector/network.py:123), monkeypatching urllib3 with ssl_wrap_socket_with_cert_revocation_checks. That wrapper (in snowflake/connector/ssl_wrap_socket.py) does:
# Ensure CA bundle default if not provided
if not params.get("ca_certs"):
params["ca_certs"] = certifi.where() # (1) defaults to certifi FIRST
provided_ctx = params.get("ssl_context")
cafile_for_ctx = _resolve_cafile(params) # (2) now params["ca_certs"] is already set
And _resolve_cafile:
def _resolve_cafile(kwargs: dict[str, Any]) -> str | None:
"""Resolve CA bundle path from kwargs or standard environment variables.
Precedence:
1) kwargs['ca_certs'] if provided by caller
2) REQUESTS_CA_BUNDLE
3) SSL_CERT_FILE
"""
caf = kwargs.get("ca_certs")
if caf:
return caf
return os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
Because step (1) always populates params["ca_certs"] with certifi.where() for callers that pass no ca_certs, _resolve_cafile at step (2) returns certifi.where() and the REQUESTS_CA_BUNDLE / SSL_CERT_FILE branches are dead code for this path. The resulting pyOpenSSL context is then built from certifi only.
This also affects the standalone urllib3.PoolManager() used for the OAuth token request in auth/_oauth_base.py, which passes no ca_certs — so token acquisition against the IdP fails even though a valid CA bundle is configured via env.
Steps to reproduce
- Environment where outbound TLS is verified against a private CA not present in certifi (e.g. a corporate TLS-inspecting proxy).
- Export the private CA bundle:
export REQUESTS_CA_BUNDLE=/path/to/private_ca_bundle.pem
export SSL_CERT_FILE=/path/to/private_ca_bundle.pem - Connect (any authenticator; OAuth client-credentials makes it obvious because the IdP token request fails first):
import snowflake.connector
conn = snowflake.connector.connect(
account="",
user="",
authenticator="OAUTH_CLIENT_CREDENTIALS",
oauth_client_id="",
oauth_client_secret="",
oauth_scope="",
oauth_token_request_url="https://login.microsoftonline.com//oauth2/v2.0/token",
)
Actual behavior
Handshake fails; the env vars are ignored:
snowflake.connector.vendored.urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(
host='login.microsoftonline.com', port=443): Max retries exceeded with url:
//oauth2/v2.0/token
(Caused by SSLError(SSLError("bad handshake: Error([('SSL routines', '',
'certificate verify failed')])")))
Minimal confirmation that the env vars are shadowed (replicating the wrapper's ordering):
import os, certifi
from snowflake.connector import ssl_wrap_socket as sws
os.environ["REQUESTS_CA_BUNDLE"] = "/path/to/private_ca_bundle.pem"
os.environ["SSL_CERT_FILE"] = "/path/to/private_ca_bundle.pem"
params = {} # e.g. the OAuth PoolManager passes no ca_certs
if not params.get("ca_certs"):
params["ca_certs"] = certifi.where() # wrapper step (1)
print(sws._resolve_cafile(params)) # -> certifi/cacert.pem, NOT the env bundle
Expected behavior
When the caller does not pass an explicit ca_certs, the connector should honor REQUESTS_CA_BUNDLE / SSL_CERT_FILE before falling back to certifi.where() — matching the documented precedence in _resolve_cafile and the behavior of requests and the stdlib ssl module (load_default_certs() honors SSL_CERT_FILE).
Suggested fix
Resolve the env-based bundle before defaulting to certifi, i.e. reorder so certifi is the last fallback:
if not params.get("ca_certs"):
params["ca_certs"] = (
os.environ.get("REQUESTS_CA_BUNDLE")
or os.environ.get("SSL_CERT_FILE")
or certifi.where()
)
Or fold the certifi fallback into _resolve_cafile itself and call it unconditionally, so there is a single source of truth for CA resolution across both the OCSP wrapper and the OAuth token PoolManager.
Workaround
Merge the private CA into certifi's roots and redirect certifi.where() at the merged bundle before connecting, since certifi is the only bundle actually consulted.
What did you expect to see?
See above
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.