2929from .vendored .urllib3 import connection as connection_
3030from .vendored .urllib3 .contrib .pyopenssl import PyOpenSSLContext , WrappedSocket
3131from .vendored .urllib3 .util import ssl_ as ssl_
32+ from .vendored .urllib3 .util .ssl_match_hostname import CertificateError , match_hostname
3233
3334if 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 )
172287def 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 ,
0 commit comments