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

Open
#2,972 1 comment 0 reactions 1 assignee View on GitHub

@sfc-gh-snow-drivers-warsaw-dl is already working on this.

Since Jul 28, 2026.

bug status-triage_done
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

  1. Environment where outbound TLS is verified against a private CA not present in certifi (e.g. a corporate TLS-inspecting proxy).
  2. 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
  3. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.