Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for TLS session resumption in HTTPSConnection #1886

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/urllib3/connection.py
Expand Up @@ -71,6 +71,9 @@ class BrokenPipeError(Exception):

_CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]")

# OP_NO_TICKET is only available in Python 3.6+
SSL_OP_NO_TICKET = getattr(ssl, "OP_NO_TICKET", None)


class HTTPConnection(_HTTPConnection, object):
"""
Expand Down Expand Up @@ -269,6 +272,7 @@ class HTTPSConnection(HTTPConnection):
"""

default_port = port_by_scheme["https"]
default_resume_ssl_sessions = True

cert_reqs = None
ca_certs = None
Expand All @@ -291,7 +295,10 @@ def __init__(
server_hostname=None,
**kw
):

# do this before calling base class:
self.resume_ssl_sessions = kw.pop(
"resume_ssl_sessions", HTTPSConnection.default_resume_ssl_sessions
)
HTTPConnection.__init__(self, host, port, strict=strict, timeout=timeout, **kw)

self.key_file = key_file
Expand All @@ -300,6 +307,8 @@ def __init__(
self.ssl_context = ssl_context
self.server_hostname = server_hostname

# This may get a value after the first call to .connect():
self._ssl_session = None
# Required property for Google AppEngine 1.9.0 which otherwise causes
# HTTPS requests to go out as HTTP. (See Issue #356)
self._protocol = "https"
Expand Down Expand Up @@ -382,6 +391,18 @@ def connect(self):
ssl_version=resolve_ssl_version(self.ssl_version),
cert_reqs=resolve_cert_reqs(self.cert_reqs),
)
if SSL_OP_NO_TICKET is not None:
# Unset this sslctx flag, so that we express
# to the server we support tickets on TLSv1.2 and below.
# On TLSv1.3, this flag does nothing, and is ignored
# by openssl, as the TLS client no longer states
# it supports tickets to the server explicitly:
if self.resume_ssl_sessions:
self.ssl_context.options &= ~SSL_OP_NO_TICKET
# In other cases, set the flag, expressing
# to the server we don't want a ticket:
else:
self.ssl_context.options |= SSL_OP_NO_TICKET

context = self.ssl_context
context.verify_mode = resolve_cert_reqs(self.cert_reqs)
Expand All @@ -397,6 +418,9 @@ def connect(self):
):
context.load_default_certs()

# If this is None, it will be the same as if it were ommitted in kwargs:
prev_ssl_session = self._ssl_session

self.sock = ssl_wrap_socket(
sock=conn,
keyfile=self.key_file,
Expand All @@ -407,6 +431,7 @@ def connect(self):
ca_cert_data=self.ca_cert_data,
server_hostname=server_hostname,
ssl_context=context,
ssl_session=prev_ssl_session,
tls_in_tls=tls_in_tls,
)

Expand Down Expand Up @@ -440,6 +465,17 @@ def connect(self):
or self.assert_fingerprint is not None
)

if prev_ssl_session is not None:
log.debug(
"For %s: new socket created, socket.session_reused=%s",
self,
getattr(self.sock, "session_reused", None),
)

curr_ssl_session = getattr(self.sock, "session", None)
if curr_ssl_session is not None and self.resume_ssl_sessions:
self._ssl_session = curr_ssl_session

def _connect_tls_proxy(self, hostname, conn):
"""
Establish a TLS connection to the proxy using the provided SSL context.
Expand Down
28 changes: 28 additions & 0 deletions src/urllib3/connectionpool.py
Expand Up @@ -872,6 +872,32 @@ class HTTPSConnectionPool(HTTPConnectionPool):
``ca_cert_dir``, ``ssl_version``, ``key_password`` are only used if :mod:`ssl`
is available and are fed into :meth:`urllib3.util.ssl_wrap_socket` to upgrade
the connection socket into an SSL socket.

When running on CPython >=3.6 compiled with :mod:`ssl` (openssl binding),
it is possible to resume TLS sessions in case the TCP socket level was dropped/closed.
This feature is enabled by default when supported by the underlying openssl bindings.
To disable it for all connections in this pool, set the kwarg ``resume_ssl_sessions``
to ``False``.

>> from urllib3 import HTTPSConnectionPool
>> pool_with_resumption_enabled = HTTPSConnectionPool('localhost', 443)
>> pool_with_resumption_disabled = HTTPSConnectionPool('localhost', 443, resume_ssl_sessions=False)

To globally change the default behavior in your application
without this explicit kwarg, it is also possible to the following:

>> from urllib3 import HTTPSConnectionPool
>> from urllib3.connection import HTTPSConnection
>> HTTPSConnection.default_resume_ssl_sessions = False
>> pool_with_resumption_disabled = HTTPSConnectionPool('localhost', 443)

.. note::
In most cases the ``Connection: keep-alive`` directive of the HTTP layer solves
largely the same issue and does it better.
However, there are some cases where ``keep-alive`` does not work,
or a middlebox does not agree with too long-lived TCP connections.
In such cases reconnection may be accelerated by TLS resumption,
which will save some CPU work and network traffic for both sides.
"""

scheme = "https"
Expand Down Expand Up @@ -1033,6 +1059,8 @@ def connection_from_url(url, **kw):
Passes additional parameters to the constructor of the appropriate
:class:`.ConnectionPool`. Useful for specifying things like
timeout, maxsize, headers, etc.
For the keyword args available, see :class:`.HTTPConnectionPool`
or :class:`.HTTPSConnectionPool`, depending on your url.

Example::

Expand Down
66 changes: 41 additions & 25 deletions src/urllib3/util/ssl_.py
Expand Up @@ -21,6 +21,8 @@
HAS_SNI = False
IS_PYOPENSSL = False
IS_SECURETRANSPORT = False
# SSLSocket.session -> version 3.6
IS_SSL_SESSION_SUPPORTED = sys.version_info >= (3, 6)
ALPN_PROTOCOLS = ["http/1.1"]

# Maps the length of a digest to a possible hash function producing this digest
Expand Down Expand Up @@ -116,6 +118,7 @@ def _const_compare_digest_backport(a, b):
try:
from ssl import SSLContext # Modern SSL?
except ImportError:
IS_SSL_SESSION_SUPPORTED = False
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should delete the other commit which disables session tickets.

The two don't conflict, this commit overwrites it in practice but it's not elegant.


class SSLContext(object): # Platform-specific: Python 2
def __init__(self, protocol_version):
Expand Down Expand Up @@ -334,10 +337,11 @@ def ssl_wrap_socket(
ca_cert_dir=None,
key_password=None,
ca_cert_data=None,
ssl_session=None,
tls_in_tls=False,
):
"""
All arguments except for server_hostname, ssl_context, and ca_cert_dir have
All arguments except for server_hostname, ssl_context, ssl_session, and ca_cert_dir have
the same meaning as they do when using :func:`ssl.wrap_socket`.

:param server_hostname:
Expand All @@ -356,8 +360,20 @@ def ssl_wrap_socket(
:param ca_cert_data:
Optional string containing CA certificates in PEM format suitable for
passing as the cadata parameter to SSLContext.load_verify_locations()
:param ssl_session:
If not ``None``, will be passed to :func:`ssl.wrap_socket`
as the `session` keyword argument.
An `_ssl.Session` object is obtained from a previously created
``SSLSocket`` object, that had a completed handshake.
If provided, the ssl layer may attempt session resumption
depending on the ssl bindings in use and whether a ticket/id
to use for resumption was sent by the SSL server, but there is
no guarantee that a resumption will be attempted, nor that it will succeed.
In case the resumption fails or does not happen, the underlying ssl
layer will fall back to a full handshake - ie. same as when establishing
a new connection, so this failure is transparent to a caller.
:param tls_in_tls:
Use SSLTransport to wrap the existing socket.
Use :class:`SSLTransport` to wrap the existing socket if available.
"""
context = ssl_context
if context is None:
Expand Down Expand Up @@ -414,13 +430,30 @@ def ssl_wrap_socket(
SNIMissingWarning,
)

wrap_sock_kwargs = {}
# do not pass it if not sending SNI
if send_sni:
ssl_sock = _ssl_wrap_socket_impl(
sock, context, tls_in_tls, server_hostname=server_hostname
)
else:
ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls)
return ssl_sock
wrap_sock_kwargs["server_hostname"] = server_hostname
# Depending on the underlying wrap_socket,
# it may not accept the `session` kwarg even if it is provided by caller
if IS_SSL_SESSION_SUPPORTED and ssl_session is not None:
wrap_sock_kwargs["session"] = ssl_session

return _ssl_wrap_socket_impl(sock, context, tls_in_tls, wrap_sock_kwargs)


def _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, wrap_sock_kwargs):
if tls_in_tls:
if not SSLTransport:
# Import error, ssl is not available.
raise ProxySchemeUnsupported(
"TLS in TLS requires support for the 'ssl' module"
)

SSLTransport._validate_ssl_context_for_tls_in_tls(ssl_context)
return SSLTransport(sock, ssl_context, **wrap_sock_kwargs)

return ssl_context.wrap_socket(sock, **wrap_sock_kwargs)


def is_ipaddress(hostname):
Expand All @@ -445,20 +478,3 @@ def _is_key_file_encrypted(key_file):
return True

return False


def _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, server_hostname=None):
if tls_in_tls:
if not SSLTransport:
# Import error, ssl is not available.
raise ProxySchemeUnsupported(
"TLS in TLS requires support for the 'ssl' module"
)

SSLTransport._validate_ssl_context_for_tls_in_tls(ssl_context)
return SSLTransport(sock, ssl_context, server_hostname)

if server_hostname:
return ssl_context.wrap_socket(sock, server_hostname=server_hostname)
else:
return ssl_context.wrap_socket(sock)
13 changes: 11 additions & 2 deletions src/urllib3/util/ssltransport.py
Expand Up @@ -42,10 +42,19 @@ def _validate_ssl_context_for_tls_in_tls(ssl_context):
)

def __init__(
self, socket, ssl_context, suppress_ragged_eofs=True, server_hostname=None
self, socket, ssl_context, suppress_ragged_eofs=True, **wrap_bio_kwargs
):
"""
Create an SSLTransport around socket using the provided ssl_context.

:param socket:
The socket object to wrap.
:param ssl_context:
:class:`ssl.SSLContext` to use call :func:`ssl.SSLContext.wrap_bio` on.
:param suppress_ragged_eofs:
Suppress ``ssl.SSL_ERROR_EOF`` read errors.
:param wrap_bio_kwargs:
These args will be passed to :func:`ssl.SSLContext.wrap_bio`
"""
self.incoming = ssl.MemoryBIO()
self.outgoing = ssl.MemoryBIO()
Expand All @@ -54,7 +63,7 @@ def __init__(
self.socket = socket

self.sslobj = ssl_context.wrap_bio(
self.incoming, self.outgoing, server_hostname=server_hostname
self.incoming, self.outgoing, **wrap_bio_kwargs
)

# Perform initial handshake.
Expand Down
6 changes: 1 addition & 5 deletions test/test_retry.py
Expand Up @@ -343,11 +343,7 @@ def test_respect_retry_after_header_propagated(self, respect_retry_after_header)
)
@pytest.mark.parametrize(
"stub_timezone",
[
"UTC",
"Asia/Jerusalem",
None,
],
["UTC", "Asia/Jerusalem", None],
indirect=True,
)
@pytest.mark.usefixtures("stub_timezone")
Expand Down