diff --git a/Dockerfile b/Dockerfile index f1b806b716..012a1259b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,11 @@ MAINTAINER Joffrey F RUN mkdir /home/docker-py WORKDIR /home/docker-py -ADD requirements.txt /home/docker-py/requirements.txt +COPY requirements.txt /home/docker-py/requirements.txt RUN pip install -r requirements.txt -ADD test-requirements.txt /home/docker-py/test-requirements.txt +COPY test-requirements.txt /home/docker-py/test-requirements.txt RUN pip install -r test-requirements.txt -ADD . /home/docker-py +COPY . /home/docker-py RUN pip install . diff --git a/Dockerfile-py3 b/Dockerfile-py3 index a19d974a00..2a04bb3748 100644 --- a/Dockerfile-py3 +++ b/Dockerfile-py3 @@ -4,11 +4,11 @@ MAINTAINER Joffrey F RUN mkdir /home/docker-py WORKDIR /home/docker-py -ADD requirements.txt /home/docker-py/requirements.txt +COPY requirements3.txt /home/docker-py/requirements.txt RUN pip install -r requirements.txt -ADD test-requirements.txt /home/docker-py/test-requirements.txt +COPY test-requirements.txt /home/docker-py/test-requirements.txt RUN pip install -r test-requirements.txt -ADD . /home/docker-py +COPY . /home/docker-py RUN pip install . diff --git a/docker/ssladapter/ssl_match_hostname.py b/docker/ssladapter/ssl_match_hostname.py new file mode 100644 index 0000000000..9de0c5f04b --- /dev/null +++ b/docker/ssladapter/ssl_match_hostname.py @@ -0,0 +1,130 @@ +# Slightly modified version of match_hostname in python's ssl library +# https://hg.python.org/cpython/file/tip/Lib/ssl.py +# Changed to make code python 2.x compatible (unicode strings for ip_address +# and 3.5-specific var assignment syntax) + +import ipaddress +import re + +try: + from ssl import CertificateError +except ImportError: + CertificateError = ValueError + +import six + + +def _ipaddress_match(ipname, host_ip): + """Exact matching of IP addresses. + + RFC 6125 explicitly doesn't define an algorithm for this + (section 1.7.2 - "Out of Scope"). + """ + # OpenSSL may add a trailing newline to a subjectAltName's IP address + ip = ipaddress.ip_address(six.text_type(ipname.rstrip())) + return ip == host_ip + + +def _dnsname_match(dn, hostname, max_wildcards=1): + """Matching according to RFC 6125, section 6.4.3 + + http://tools.ietf.org/html/rfc6125#section-6.4.3 + """ + pats = [] + if not dn: + return False + + split_dn = dn.split(r'.') + leftmost, remainder = split_dn[0], split_dn[1:] + + wildcards = leftmost.count('*') + if wildcards > max_wildcards: + # Issue #17980: avoid denials of service by refusing more + # than one wildcard per fragment. A survey of established + # policy among SSL implementations showed it to be a + # reasonable choice. + raise CertificateError( + "too many wildcards in certificate DNS name: " + repr(dn)) + + # speed up common case w/o wildcards + if not wildcards: + return dn.lower() == hostname.lower() + + # RFC 6125, section 6.4.3, subitem 1. + # The client SHOULD NOT attempt to match a presented identifier in which + # the wildcard character comprises a label other than the left-most label. + if leftmost == '*': + # When '*' is a fragment by itself, it matches a non-empty dotless + # fragment. + pats.append('[^.]+') + elif leftmost.startswith('xn--') or hostname.startswith('xn--'): + # RFC 6125, section 6.4.3, subitem 3. + # The client SHOULD NOT attempt to match a presented identifier + # where the wildcard character is embedded within an A-label or + # U-label of an internationalized domain name. + pats.append(re.escape(leftmost)) + else: + # Otherwise, '*' matches any dotless string, e.g. www* + pats.append(re.escape(leftmost).replace(r'\*', '[^.]*')) + + # add the remaining fragments, ignore any wildcards + for frag in remainder: + pats.append(re.escape(frag)) + + pat = re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE) + return pat.match(hostname) + + +def match_hostname(cert, hostname): + """Verify that *cert* (in decoded format as returned by + SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125 + rules are followed, but IP addresses are not accepted for *hostname*. + + CertificateError is raised on failure. On success, the function + returns nothing. + """ + if not cert: + raise ValueError("empty or no certificate, match_hostname needs a " + "SSL socket or SSL context with either " + "CERT_OPTIONAL or CERT_REQUIRED") + try: + host_ip = ipaddress.ip_address(six.text_type(hostname)) + except ValueError: + # Not an IP address (common case) + host_ip = None + dnsnames = [] + san = cert.get('subjectAltName', ()) + for key, value in san: + if key == 'DNS': + if host_ip is None and _dnsname_match(value, hostname): + return + dnsnames.append(value) + elif key == 'IP Address': + if host_ip is not None and _ipaddress_match(value, host_ip): + return + dnsnames.append(value) + if not dnsnames: + # The subject is only checked when there is no dNSName entry + # in subjectAltName + for sub in cert.get('subject', ()): + for key, value in sub: + # XXX according to RFC 2818, the most specific Common Name + # must be used. + if key == 'commonName': + if _dnsname_match(value, hostname): + return + dnsnames.append(value) + if len(dnsnames) > 1: + raise CertificateError( + "hostname %r doesn't match either of %s" + % (hostname, ', '.join(map(repr, dnsnames)))) + elif len(dnsnames) == 1: + raise CertificateError( + "hostname %r doesn't match %r" + % (hostname, dnsnames[0]) + ) + else: + raise CertificateError( + "no appropriate commonName or " + "subjectAltName fields were found" + ) diff --git a/docker/ssladapter/ssladapter.py b/docker/ssladapter/ssladapter.py index 5b43aa2ed9..179510c783 100644 --- a/docker/ssladapter/ssladapter.py +++ b/docker/ssladapter/ssladapter.py @@ -2,6 +2,8 @@ https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ https://github.com/kennethreitz/requests/pull/799 """ +import sys + from distutils.version import StrictVersion from requests.adapters import HTTPAdapter @@ -10,8 +12,15 @@ except ImportError: import urllib3 + PoolManager = urllib3.poolmanager.PoolManager +# Monkey-patching match_hostname with a version that supports +# IP-address checking. Not necessary for Python 3.5 and above +if sys.version_info[0] < 3 or sys.version_info[1] < 5: + from .ssl_match_hostname import match_hostname + urllib3.connection.match_hostname = match_hostname + class SSLAdapter(HTTPAdapter): '''An HTTPS Transport Adapter that uses an arbitrary SSL version.''' diff --git a/docker/unixconn/unixconn.py b/docker/unixconn/unixconn.py index cbf1c2d4cf..f4d83ef309 100644 --- a/docker/unixconn/unixconn.py +++ b/docker/unixconn/unixconn.py @@ -31,7 +31,8 @@ class UnixHTTPConnection(httplib.HTTPConnection, object): def __init__(self, base_url, unix_socket, timeout=60): super(UnixHTTPConnection, self).__init__( - 'localhost', timeout=timeout) + 'localhost', timeout=timeout + ) self.base_url = base_url self.unix_socket = unix_socket self.timeout = timeout @@ -46,7 +47,8 @@ def connect(self): class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): def __init__(self, base_url, socket_path, timeout=60): super(UnixHTTPConnectionPool, self).__init__( - 'localhost', timeout=timeout) + 'localhost', timeout=timeout + ) self.base_url = base_url self.socket_path = socket_path self.timeout = timeout diff --git a/requirements.txt b/requirements.txt index 72c255d318..5fec765f02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests==2.5.3 six>=1.4.0 websocket-client==0.32.0 +py2-ipaddress==3.4.1 \ No newline at end of file diff --git a/requirements3.txt b/requirements3.txt new file mode 100644 index 0000000000..a9da839a23 --- /dev/null +++ b/requirements3.txt @@ -0,0 +1,3 @@ +requests==2.5.3 +six>=1.4.0 +websocket-client==0.32.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 6d8616331d..44cd164abc 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,9 @@ 'websocket-client >= 0.32.0', ] +if sys.version_info[0] == 2: + requirements.append('py2-ipaddress >= 3.4.1') + exec(open('docker/version.py').read()) with open('./test-requirements.txt') as test_reqs_txt: diff --git a/tests/unit/ssladapter_test.py b/tests/unit/ssladapter_test.py new file mode 100644 index 0000000000..fa9c77ad44 --- /dev/null +++ b/tests/unit/ssladapter_test.py @@ -0,0 +1,73 @@ +from docker.ssladapter import ssladapter +from docker.ssladapter.ssl_match_hostname import ( + match_hostname, CertificateError +) + +try: + from ssl import OP_NO_SSLv3, OP_NO_SSLv2, OP_NO_TLSv1 +except ImportError: + OP_NO_SSLv2 = 0x1000000 + OP_NO_SSLv3 = 0x2000000 + OP_NO_TLSv1 = 0x4000000 + +from .. import base + + +class SSLAdapterTest(base.BaseTestCase): + def test_only_uses_tls(self): + ssl_context = ssladapter.urllib3.util.ssl_.create_urllib3_context() + + assert ssl_context.options & OP_NO_SSLv3 + assert ssl_context.options & OP_NO_SSLv2 + assert not ssl_context.options & OP_NO_TLSv1 + + +class MatchHostnameTest(base.BaseTestCase): + cert = { + 'issuer': ( + (('countryName', u'US'),), + (('stateOrProvinceName', u'California'),), + (('localityName', u'San Francisco'),), + (('organizationName', u'Docker Inc'),), + (('organizationalUnitName', u'Docker-Python'),), + (('commonName', u'localhost'),), + (('emailAddress', u'info@docker.com'),) + ), + 'notAfter': 'Mar 25 23:08:23 2030 GMT', + 'notBefore': u'Mar 25 23:08:23 2016 GMT', + 'serialNumber': u'BD5F894C839C548F', + 'subject': ( + (('countryName', u'US'),), + (('stateOrProvinceName', u'California'),), + (('localityName', u'San Francisco'),), + (('organizationName', u'Docker Inc'),), + (('organizationalUnitName', u'Docker-Python'),), + (('commonName', u'localhost'),), + (('emailAddress', u'info@docker.com'),) + ), + 'subjectAltName': ( + ('DNS', u'localhost'), + ('DNS', u'*.gensokyo.jp'), + ('IP Address', u'127.0.0.1'), + ), + 'version': 3 + } + + def test_match_ip_address_success(self): + assert match_hostname(self.cert, '127.0.0.1') is None + + def test_match_localhost_success(self): + assert match_hostname(self.cert, 'localhost') is None + + def test_match_dns_success(self): + assert match_hostname(self.cert, 'touhou.gensokyo.jp') is None + + def test_match_ip_address_failure(self): + self.assertRaises( + CertificateError, match_hostname, self.cert, '192.168.0.25' + ) + + def test_match_dns_failure(self): + self.assertRaises( + CertificateError, match_hostname, self.cert, 'foobar.co.uk' + ) diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index eb952b2f32..aed51d40e7 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -12,17 +12,9 @@ import pytest import six -try: - from ssl import OP_NO_SSLv3, OP_NO_SSLv2, OP_NO_TLSv1 -except ImportError: - OP_NO_SSLv2 = 0x1000000 - OP_NO_SSLv3 = 0x2000000 - OP_NO_TLSv1 = 0x4000000 - from docker.client import Client from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import DockerException, InvalidVersion -from docker.ssladapter import ssladapter from docker.utils import ( parse_repository_tag, parse_host, convert_filters, kwargs_from_env, create_host_config, Ulimit, LogConfig, parse_bytes, parse_env_file, @@ -962,12 +954,3 @@ def test_tar_with_directory_symlinks(self): self.assertEqual( sorted(tar_data.getnames()), ['bar', 'bar/foo', 'foo'] ) - - -class SSLAdapterTest(base.BaseTestCase): - def test_only_uses_tls(self): - ssl_context = ssladapter.urllib3.util.ssl_.create_urllib3_context() - - assert ssl_context.options & OP_NO_SSLv3 - assert ssl_context.options & OP_NO_SSLv2 - assert not ssl_context.options & OP_NO_TLSv1 diff --git a/tox.ini b/tox.ini index 40e46fafbb..483c3ae075 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,8 @@ commands = py.test --cov=docker {posargs:tests/unit} deps = -r{toxinidir}/test-requirements.txt - -r{toxinidir}/requirements.txt + {py26,py27}: -r{toxinidir}/requirements.txt + {py33,py34}: -r{toxinidir}/requirements3.txt [testenv:flake8] commands = flake8 docker tests