Skip to content

Commit

Permalink
bpo-31399: Let OpenSSL verify hostname and IP address (python#3462)
Browse files Browse the repository at this point in the history
bpo-31399: Let OpenSSL verify hostname and IP

The ssl module now uses OpenSSL's X509_VERIFY_PARAM_set1_host() and
X509_VERIFY_PARAM_set1_ip() API to verify hostname and IP addresses.

* Remove match_hostname calls
* Check for libssl with set1_host, libssl must provide X509_VERIFY_PARAM_set1_host()
* Add documentation for OpenSSL 1.0.2 requirement
* Don't support OpenSSL special mode with a leading dot, e.g. ".example.org" matches "www.example.org". It's not standard conform.
* Add hostname_checks_common_name

Signed-off-by: Christian Heimes <christian@python.org>
  • Loading branch information
tiran authored Jan 27, 2018
1 parent 746cc75 commit 61d478c
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 73 deletions.
44 changes: 40 additions & 4 deletions Doc/library/ssl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,10 @@ Functions, Constants, and Exceptions

.. exception:: CertificateError

Raised to signal an error with a certificate (such as mismatching
hostname). Certificate errors detected by OpenSSL, though, raise
an :exc:`SSLCertVerificationError`.
An alias for :exc:`SSLCertVerificationError`.

.. versionchanged:: 3.7
The exception is now an alias for :exc:`SSLCertVerificationError`.


Socket creation
Expand Down Expand Up @@ -430,8 +431,14 @@ Certificate handling
of the certificate, is now supported.

.. versionchanged:: 3.7
The function is no longer used to TLS connections. Hostname matching
is now performed by OpenSSL.

Allow wildcard when it is the leftmost and the only character
in that segment.
in that segment. Partial wildcards like ``www*.example.com`` are no
longer supported.

.. deprecated:: 3.7

.. function:: cert_time_to_seconds(cert_time)

Expand Down Expand Up @@ -850,6 +857,14 @@ Constants

.. versionadded:: 3.5

.. data:: HAS_NEVER_CHECK_COMMON_NAME

Whether the OpenSSL library has built-in support not checking subject
common name and :attr:`SSLContext.hostname_checks_common_name` is
writeable.

.. versionadded:: 3.7

.. data:: HAS_ECDH

Whether the OpenSSL library has built-in support for Elliptic Curve-based
Expand Down Expand Up @@ -1075,6 +1090,12 @@ SSL sockets also have the following additional methods and attributes:
The socket timeout is no more reset each time bytes are received or sent.
The socket timeout is now to maximum total duration of the handshake.

.. versionchanged:: 3.7
Hostname or IP address is matched by OpenSSL during handshake. The
function :func:`match_hostname` is no longer used. In case OpenSSL
refuses a hostname or IP address, the handshake is aborted early and
a TLS alert message is send to the peer.

.. method:: SSLSocket.getpeercert(binary_form=False)

If there is no certificate for the peer on the other end of the connection,
Expand Down Expand Up @@ -1730,6 +1751,17 @@ to speed up repeated connections from the same clients.
The protocol version chosen when constructing the context. This attribute
is read-only.

.. attribute:: SSLContext.hostname_checks_common_name

Whether :attr:`~SSLContext.check_hostname` falls back to verify the cert's
subject common name in the absence of a subject alternative name
extension (default: true).

.. versionadded:: 3.7

.. note::
Only writeable with OpenSSL 1.1.0 or higher.

.. attribute:: SSLContext.verify_flags

The flags for certificate verification operations. You can set flags like
Expand Down Expand Up @@ -2324,6 +2356,10 @@ in this case, the :func:`match_hostname` function can be used. This common
check is automatically performed when :attr:`SSLContext.check_hostname` is
enabled.

.. versionchanged:: 3.7
Hostname matchings is now performed by OpenSSL. Python no longer uses
:func:`match_hostname`.

In server mode, if you want to authenticate your clients using the SSL layer
(rather than using a higher-level authentication mechanism), you'll also have
to specify :const:`CERT_REQUIRED` and similarly check the client certificate.
Expand Down
32 changes: 32 additions & 0 deletions Doc/whatsnew/3.7.rst
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,32 @@ can be set within the scope of a group.
``'^$'`` or ``(?=-)`` that matches an empty string.
(Contributed by Serhiy Storchaka in :issue:`25054`.)

ssl
---

The ssl module now uses OpenSSL's builtin API instead of
:func:`~ssl.match_hostname` to check host name or IP address. Values
are validated during TLS handshake. Any cert validation error including
a failing host name match now raises :exc:`~ssl.SSLCertVerificationError` and
aborts the handshake with a proper TLS Alert message. The new exception
contains additional information. Host name validation can be customized
with :attr:`~ssl.SSLContext.host_flags`.
(Contributed by Christian Heimes in :issue:`31399`.)

.. note::
The improved host name check requires an OpenSSL 1.0.2 or 1.1 compatible
libssl. OpenSSL 0.9.8 and 1.0.1 are no longer supported. LibreSSL is
temporarily not supported until it gains the necessary OpenSSL 1.0.2 APIs.

The ssl module no longer sends IP addresses in SNI TLS extension.
(Contributed by Christian Heimes in :issue:`32185`.)

:func:`~ssl.match_hostname` no longer supports partial wildcards like
``www*.example.org``. :attr:`~ssl.SSLContext.host_flags` has partial
wildcard matching disabled by default.
(Contributed by Mandeep Singh in :issue:`23033` and Christian Heimes in
:issue:`31399`.)

string
------

Expand Down Expand Up @@ -1120,6 +1146,12 @@ Other CPython implementation changes
emitted in the first place), and an explicit ``error::BytesWarning``
warnings filter added to convert them to exceptions.

* CPython' :mod:`ssl` module requires OpenSSL 1.0.2 or 1.1 compatible libssl.
OpenSSL 1.0.1 has reached end of lifetime on 2016-12-31 and is no longer
supported. LibreSSL is temporarily not supported as well. LibreSSL releases
up to version 2.6.4 are missing required OpenSSL 1.0.2 APIs.


Documentation
=============

Expand Down
6 changes: 0 additions & 6 deletions Lib/asyncio/sslproto.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,12 +590,6 @@ def _on_handshake_complete(self, handshake_exc):
raise handshake_exc

peercert = sslobj.getpeercert()
if not hasattr(self._sslcontext, 'check_hostname'):
# Verify hostname if requested, Python 3.4+ uses check_hostname
# and checks the hostname in do_handshake()
if (self._server_hostname and
self._sslcontext.verify_mode != ssl.CERT_NONE):
ssl.match_hostname(peercert, self._server_hostname)
except BaseException as exc:
if self._loop.get_debug():
if isinstance(exc, ssl.CertificateError):
Expand Down
10 changes: 2 additions & 8 deletions Lib/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1375,7 +1375,8 @@ def __init__(self, host, port=None, key_file=None, cert_file=None,
if key_file or cert_file:
context.load_cert_chain(cert_file, key_file)
self._context = context
self._check_hostname = check_hostname
if check_hostname is not None:
self._context.check_hostname = check_hostname

def connect(self):
"Connect to a host on a given (SSL) port."
Expand All @@ -1389,13 +1390,6 @@ def connect(self):

self.sock = self._context.wrap_socket(self.sock,
server_hostname=server_hostname)
if not self._context.check_hostname and self._check_hostname:
try:
ssl.match_hostname(self.sock.getpeercert(), server_hostname)
except Exception:
self.sock.shutdown(socket.SHUT_RDWR)
self.sock.close()
raise

__all__.append("HTTPSConnection")

Expand Down
29 changes: 20 additions & 9 deletions Lib/ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@
lambda name: name.startswith('CERT_'),
source=_ssl)


PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_TLS
_PROTOCOL_NAMES = {value: name for name, value in _SSLMethod.__members__.items()}

Expand All @@ -172,6 +171,8 @@
else:
CHANNEL_BINDING_TYPES = []

HAS_NEVER_CHECK_COMMON_NAME = hasattr(_ssl, 'HOSTFLAG_NEVER_CHECK_SUBJECT')


# Disable weak or insecure ciphers by default
# (OpenSSL's default setting is 'DEFAULT:!aNULL:!eNULL')
Expand Down Expand Up @@ -216,9 +217,7 @@
'!aNULL:!eNULL:!MD5:!DSS:!RC4:!3DES'
)


class CertificateError(ValueError):
pass
CertificateError = SSLCertVerificationError


def _dnsname_match(dn, hostname):
Expand Down Expand Up @@ -473,6 +472,23 @@ def options(self):
def options(self, value):
super(SSLContext, SSLContext).options.__set__(self, value)

if hasattr(_ssl, 'HOSTFLAG_NEVER_CHECK_SUBJECT'):
@property
def hostname_checks_common_name(self):
ncs = self._host_flags & _ssl.HOSTFLAG_NEVER_CHECK_SUBJECT
return ncs != _ssl.HOSTFLAG_NEVER_CHECK_SUBJECT

@hostname_checks_common_name.setter
def hostname_checks_common_name(self, value):
if value:
self._host_flags &= ~_ssl.HOSTFLAG_NEVER_CHECK_SUBJECT
else:
self._host_flags |= _ssl.HOSTFLAG_NEVER_CHECK_SUBJECT
else:
@property
def hostname_checks_common_name(self):
return True

@property
def verify_flags(self):
return VerifyFlags(super().verify_flags)
Expand Down Expand Up @@ -699,11 +715,6 @@ def pending(self):
def do_handshake(self):
"""Start the SSL/TLS handshake."""
self._sslobj.do_handshake()
if self.context.check_hostname:
if not self.server_hostname:
raise ValueError("check_hostname needs server_hostname "
"argument")
match_hostname(self.getpeercert(), self.server_hostname)

def unwrap(self):
"""Start the SSL shutdown handshake."""
Expand Down
6 changes: 4 additions & 2 deletions Lib/test/test_asyncio/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1148,11 +1148,13 @@ def test_create_server_ssl_match_failed(self):
with test_utils.disable_logger():
with self.assertRaisesRegex(
ssl.CertificateError,
"hostname '127.0.0.1' doesn't match 'localhost'"):
"IP address mismatch, certificate is not valid for "
"'127.0.0.1'"):
self.loop.run_until_complete(f_c)

# close connection
proto.transport.close()
# transport is None because TLS ALERT aborted the handshake
self.assertIsNone(proto.transport)
server.close()

@support.skip_unless_bind_unix_socket
Expand Down
3 changes: 3 additions & 0 deletions Lib/test/test_ftplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ def _do_ssl_handshake(self):
return
elif err.args[0] == ssl.SSL_ERROR_EOF:
return self.handle_close()
# TODO: SSLError does not expose alert information
elif "SSLV3_ALERT_BAD_CERTIFICATE" in err.args[1]:
return self.handle_close()
raise
except OSError as err:
if err.args[0] == errno.ECONNABORTED:
Expand Down
6 changes: 4 additions & 2 deletions Lib/test/test_imaplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,8 @@ def test_ssl_raises(self):
ssl_context.load_verify_locations(CAFILE)

with self.assertRaisesRegex(ssl.CertificateError,
"hostname '127.0.0.1' doesn't match 'localhost'"):
"IP address mismatch, certificate is not valid for "
"'127.0.0.1'"):
_, server = self._setup(SimpleIMAPHandler)
client = self.imap_class(*server.server_address,
ssl_context=ssl_context)
Expand Down Expand Up @@ -874,7 +875,8 @@ def test_ssl_verified(self):

with self.assertRaisesRegex(
ssl.CertificateError,
"hostname '127.0.0.1' doesn't match 'localhost'"):
"IP address mismatch, certificate is not valid for "
"'127.0.0.1'"):
with self.reaped_server(SimpleIMAPHandler) as server:
client = self.imap_class(*server.server_address,
ssl_context=ssl_context)
Expand Down
3 changes: 3 additions & 0 deletions Lib/test/test_poplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ def _do_tls_handshake(self):
return
elif err.args[0] == ssl.SSL_ERROR_EOF:
return self.handle_close()
# TODO: SSLError does not expose alert information
elif "SSLV3_ALERT_BAD_CERTIFICATE" in err.args[1]:
return self.handle_close()
raise
except OSError as err:
if err.args[0] == errno.ECONNABORTED:
Expand Down
28 changes: 26 additions & 2 deletions Lib/test/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,19 @@ def test_verify_mode_protocol(self):
self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED)
self.assertTrue(ctx.check_hostname)

def test_hostname_checks_common_name(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
self.assertTrue(ctx.hostname_checks_common_name)
if ssl.HAS_NEVER_CHECK_COMMON_NAME:
ctx.hostname_checks_common_name = True
self.assertTrue(ctx.hostname_checks_common_name)
ctx.hostname_checks_common_name = False
self.assertFalse(ctx.hostname_checks_common_name)
ctx.hostname_checks_common_name = True
self.assertTrue(ctx.hostname_checks_common_name)
else:
with self.assertRaises(AttributeError):
ctx.hostname_checks_common_name = True

@unittest.skipUnless(have_verify_flags(),
"verify_flags need OpenSSL > 0.9.8")
Expand Down Expand Up @@ -1511,6 +1524,16 @@ def test_bad_idna_in_server_hostname(self):
ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
server_hostname="xn--.com")

def test_bad_server_hostname(self):
ctx = ssl.create_default_context()
with self.assertRaises(ValueError):
ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
server_hostname="")
with self.assertRaises(ValueError):
ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
server_hostname=".example.org")


class MemoryBIOTests(unittest.TestCase):

def test_read_write(self):
Expand Down Expand Up @@ -2536,8 +2559,9 @@ def test_check_hostname(self):
with server:
with client_context.wrap_socket(socket.socket(),
server_hostname="invalid") as s:
with self.assertRaisesRegex(ssl.CertificateError,
"hostname 'invalid' doesn't match 'localhost'"):
with self.assertRaisesRegex(
ssl.CertificateError,
"Hostname mismatch, certificate is not valid for 'invalid'."):
s.connect((HOST, server.port))

# missing server_hostname arg should cause an exception, too
Expand Down
2 changes: 1 addition & 1 deletion Lib/test/test_urllib2_localnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ def test_https_with_cafile(self):
cafile=CERT_fakehostname)
# Good cert, but mismatching hostname
handler = self.start_https_server(certfile=CERT_fakehostname)
with self.assertRaises(ssl.CertificateError) as cm:
with self.assertRaises(urllib.error.URLError) as cm:
self.urlopen("https://localhost:%s/bizarre" % handler.port,
cafile=CERT_fakehostname)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
The ssl module now uses OpenSSL's X509_VERIFY_PARAM_set1_host() and
X509_VERIFY_PARAM_set1_ip() API to verify hostname and IP addresses. Subject
common name fallback can be disabled with
SSLContext.hostname_checks_common_name.
Loading

0 comments on commit 61d478c

Please sign in to comment.