Skip to content

Commit af8c392

Browse files
Prepare release 4.7.1 (#2947)
1 parent 267d513 commit af8c392

6 files changed

Lines changed: 588 additions & 7 deletions

File tree

DESCRIPTION.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ https://docs.snowflake.com/
77
Source code is also available at: https://github.com/snowflakedb/snowflake-connector-python
88

99
# Release Notes
10-
- NEXT_RELEASE(TBD)
10+
- v4.7.1(Jul 15,2026)
1111
- Added support for Python 3.14t (free-threaded).
1212
- **Note:** Python 3.14t CI testing excludes `win_arm64` (no `cryptography` wheels available) and `mitmproxy` proxy tests on all platforms (transitive dependencies `aioquic`/`pylsqpack` lack free-threaded-compatible wheels).
13-
14-
- v4.7.0(Jul 2,2026)
13+
- Improved verification of TLS connections (SNOW-3675579).
1514
- Fixed `python-connector.log` not rotating on Windows, and every record being logged twice, when easy logging is enabled via `config.toml` (SNOW-3680325).
1615
- **Note:** As part of this fix, easy logging no longer calls `logging.basicConfig()` and therefore no longer configures the root logger. `python-connector.log` now captures only the `snowflake.connector`, `botocore`, and `boto3`.
1716
- Improved URL validation reliability by replacing the hand-rolled regex in `is_valid_url()` with `urllib.parse.urlparse` (SNOW-3392651).

src/snowflake/connector/ssl_wrap_socket.py

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .vendored.urllib3 import connection as connection_
3030
from .vendored.urllib3.contrib.pyopenssl import PyOpenSSLContext, WrappedSocket
3131
from .vendored.urllib3.util import ssl_ as ssl_
32+
from .vendored.urllib3.util.ssl_match_hostname import CertificateError, match_hostname
3233

3334
if TYPE_CHECKING:
3435
from cryptography import x509
@@ -85,13 +86,79 @@ def _ensure_partial_chain_on_context(ctx: PyOpenSSLContext, cafile: str | None)
8586
pass
8687

8788

88-
def _build_context_with_partial_chain(cafile: str | None) -> PyOpenSSLContext:
89-
"""Create PyOpenSSL context configured for CERT_REQUIRED and partial-chain trust."""
89+
def _nonnegative_options(value: int) -> int:
90+
"""Return *value* as the non-negative bitmask pyOpenSSL/cryptography expects.
91+
92+
``ssl.SSLContext.options`` is exposed through a signed, platform-width C
93+
``long``. On Windows that type is 32 bits, so the common default mask (which
94+
has bit 31 set, e.g. ``0x82520050``) is returned as a *negative* Python int.
95+
cryptography's binding marshals the value into an unsigned parameter and
96+
rejects negatives with ``OverflowError: can't convert negative number to
97+
unsigned`` -- which previously aborted every Windows TLS handshake the
98+
moment we carried these options onto the substituted ``PyOpenSSLContext``.
99+
Recover the intended unsigned 32-bit mask; values that are already
100+
non-negative (every other platform) pass through unchanged.
101+
"""
102+
return value & 0xFFFFFFFF if value < 0 else value
103+
104+
105+
def _apply_stdlib_hardening(dst: PyOpenSSLContext, src: ssl.SSLContext | None) -> None:
106+
"""Carry TLS hardening from a stdlib ``SSLContext`` onto ``dst``.
107+
108+
The connector replaces the stdlib ``ssl.SSLContext`` that urllib3 builds
109+
(or that a caller supplied) with a ``PyOpenSSLContext``. Without copying the
110+
original context's hardening forward, the substitution silently drops the
111+
TLS-version floor and ``OP_NO_*`` options urllib3 configured and
112+
any hardening a caller set on a supplied context. Copy the
113+
settings we can read back; fall back to urllib3's default floor when there
114+
is no source context to mirror.
115+
116+
Limitation: cipher restrictions and pinned CA material (``cadata`` /
117+
``load_verify_locations``) cannot be read back out of an ``ssl.SSLContext``,
118+
so they cannot be transferred here. Honoring caller-supplied pinning needs a
119+
dedicated, supported channel and is tracked as a follow-up.
120+
"""
121+
if isinstance(src, ssl.SSLContext):
122+
# Mirror the protocol-version floor/ceiling and OpenSSL options the
123+
# original context carried (e.g. TLS 1.2 minimum, OP_NO_SSLv3,
124+
# OP_NO_COMPRESSION) plus any caller hardening (e.g. VERIFY_X509_STRICT).
125+
for attr in ("minimum_version", "maximum_version", "verify_flags"):
126+
try:
127+
setattr(dst, attr, getattr(src, attr))
128+
except (ValueError, OSError, OpenSSL.SSL.Error):
129+
# Best-effort; an unsupported value must not break the handshake.
130+
pass
131+
try:
132+
dst.options |= _nonnegative_options(src.options)
133+
except (ValueError, OSError, OpenSSL.SSL.Error, OverflowError, TypeError):
134+
# Best-effort; carrying options forward must never break the
135+
# handshake even if a value can't be marshalled into pyOpenSSL.
136+
pass
137+
else:
138+
# No source context to mirror (no ssl_context was supplied): restore the
139+
# hardening urllib3's create_urllib3_context() would have applied.
140+
try:
141+
dst.minimum_version = ssl.TLSVersion.TLSv1_2
142+
dst.options |= ssl.OP_NO_COMPRESSION
143+
except (ValueError, OSError, OpenSSL.SSL.Error, OverflowError, TypeError):
144+
pass
145+
146+
147+
def _build_context_with_partial_chain(
148+
cafile: str | None, src_context: ssl.SSLContext | None = None
149+
) -> PyOpenSSLContext:
150+
"""Create PyOpenSSL context configured for CERT_REQUIRED and partial-chain trust.
151+
152+
When ``src_context`` is the stdlib context being replaced, its TLS hardening
153+
(version floor, options, verify flags) is carried forward so the
154+
substitution does not weaken the connection.
155+
"""
90156
ctx = PyOpenSSLContext(ssl_.PROTOCOL_TLS_CLIENT)
91157
try:
92158
ctx.verify_mode = ssl.CERT_REQUIRED
93159
except Exception:
94160
pass
161+
_apply_stdlib_hardening(ctx, src_context)
95162
_ensure_partial_chain_on_context(ctx, cafile)
96163
return ctx
97164

@@ -168,6 +235,54 @@ def _load_trusted_certificates(cafile: str | None) -> list[x509.Certificate]:
168235
return [load_der_x509_certificate(cert, default_backend()) for cert in certs]
169236

170237

238+
def _verify_hostname_after_handshake(
239+
wrapped_socket: WrappedSocket,
240+
server_hostname: str | None,
241+
ssl_context: Any,
242+
) -> None:
243+
"""Match the peer certificate against *server_hostname*."""
244+
# Honor explicitly-disabled certificate verification (CERT_NONE) first; when
245+
# verification is off there is nothing to assert about server identity, so a
246+
# missing hostname is acceptable. Check this before the hostname so we only
247+
# skip when the caller genuinely opted out of verification.
248+
verify_mode = getattr(ssl_context, "verify_mode", ssl.CERT_REQUIRED)
249+
if verify_mode == ssl.CERT_NONE:
250+
return
251+
252+
# Verification is required but there is no host to match against (e.g. TLS
253+
# without SNI). Fail closed rather than accepting the peer: without a
254+
# hostname we cannot assert server identity.
255+
if not server_hostname:
256+
raise CertificateError(
257+
"no server hostname supplied to match against the peer certificate; "
258+
"cannot verify server identity"
259+
)
260+
261+
# Normalize bracketed / scoped IPv6 literals the same way urllib3 does:
262+
# strip the brackets and drop any "%scope" suffix before testing for an IP,
263+
# since Python's ssl module treats scoped addresses as DNS hostnames.
264+
normalized = server_hostname.strip("[]")
265+
if "%" in normalized:
266+
normalized = normalized[: normalized.rfind("%")]
267+
if ssl_.is_ipaddress(normalized):
268+
server_hostname = normalized
269+
270+
cert = wrapped_socket.getpeercert()
271+
try:
272+
match_hostname(cert, server_hostname)
273+
except CertificateError as e:
274+
log.warning(
275+
"Certificate did not match expected hostname: %s. Certificate: %s",
276+
server_hostname,
277+
cert,
278+
)
279+
# Attach the cert so callers catching CertificateError can inspect it,
280+
# matching urllib3's own _match_hostname behavior.
281+
e._peer_cert = cert
282+
wrapped_socket.close()
283+
raise
284+
285+
171286
@wraps(ssl_.ssl_wrap_socket)
172287
def ssl_wrap_socket_with_cert_revocation_checks(
173288
*args: Any, **kwargs: Any
@@ -186,13 +301,19 @@ def ssl_wrap_socket_with_cert_revocation_checks(
186301
provided_ctx = params.get("ssl_context")
187302
cafile_for_ctx = _resolve_cafile(params)
188303
if not isinstance(provided_ctx, PyOpenSSLContext):
189-
params["ssl_context"] = _build_context_with_partial_chain(cafile_for_ctx)
304+
# Carry the replaced stdlib context's TLS hardening forward so the
305+
# substitution doesn't silently weaken the connection.
306+
params["ssl_context"] = _build_context_with_partial_chain(
307+
cafile_for_ctx, src_context=provided_ctx
308+
)
190309
else:
191310
# If a PyOpenSSLContext is provided, ensure it trusts the provided CA and partial-chain is enabled
192311
_ensure_partial_chain_on_context(provided_ctx, cafile_for_ctx)
193312

194313
ret = ssl_.ssl_wrap_socket(**params)
195314

315+
_verify_hostname_after_handshake(ret, server_hostname, params.get("ssl_context"))
316+
196317
log.debug(
197318
"CRL Check Mode: %s",
198319
FEATURE_CRL_CONFIG.cert_revocation_check_mode.name,

src/snowflake/connector/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Update this for the versions
22
# Don't change the fourth version number from None
3-
VERSION = (4, 7, 0, None)
3+
VERSION = (4, 7, 1, None)

0 commit comments

Comments
 (0)