Skip to content

Commit 8879078

Browse files
committed
Merge pull request #1013 from docker/hostname_ip_matching
Hostname IP matching
2 parents 01cf62b + ac3d4aa commit 8879078

File tree

11 files changed

+231
-26
lines changed

11 files changed

+231
-26
lines changed

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ MAINTAINER Joffrey F <[email protected]>
44
RUN mkdir /home/docker-py
55
WORKDIR /home/docker-py
66

7-
ADD requirements.txt /home/docker-py/requirements.txt
7+
COPY requirements.txt /home/docker-py/requirements.txt
88
RUN pip install -r requirements.txt
99

10-
ADD test-requirements.txt /home/docker-py/test-requirements.txt
10+
COPY test-requirements.txt /home/docker-py/test-requirements.txt
1111
RUN pip install -r test-requirements.txt
1212

13-
ADD . /home/docker-py
13+
COPY . /home/docker-py
1414
RUN pip install .

Dockerfile-py3

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ MAINTAINER Joffrey F <[email protected]>
44
RUN mkdir /home/docker-py
55
WORKDIR /home/docker-py
66

7-
ADD requirements.txt /home/docker-py/requirements.txt
7+
COPY requirements3.txt /home/docker-py/requirements.txt
88
RUN pip install -r requirements.txt
99

10-
ADD test-requirements.txt /home/docker-py/test-requirements.txt
10+
COPY test-requirements.txt /home/docker-py/test-requirements.txt
1111
RUN pip install -r test-requirements.txt
1212

13-
ADD . /home/docker-py
13+
COPY . /home/docker-py
1414
RUN pip install .
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Slightly modified version of match_hostname in python's ssl library
2+
# https://hg.python.org/cpython/file/tip/Lib/ssl.py
3+
# Changed to make code python 2.x compatible (unicode strings for ip_address
4+
# and 3.5-specific var assignment syntax)
5+
6+
import ipaddress
7+
import re
8+
9+
try:
10+
from ssl import CertificateError
11+
except ImportError:
12+
CertificateError = ValueError
13+
14+
import six
15+
16+
17+
def _ipaddress_match(ipname, host_ip):
18+
"""Exact matching of IP addresses.
19+
20+
RFC 6125 explicitly doesn't define an algorithm for this
21+
(section 1.7.2 - "Out of Scope").
22+
"""
23+
# OpenSSL may add a trailing newline to a subjectAltName's IP address
24+
ip = ipaddress.ip_address(six.text_type(ipname.rstrip()))
25+
return ip == host_ip
26+
27+
28+
def _dnsname_match(dn, hostname, max_wildcards=1):
29+
"""Matching according to RFC 6125, section 6.4.3
30+
31+
http://tools.ietf.org/html/rfc6125#section-6.4.3
32+
"""
33+
pats = []
34+
if not dn:
35+
return False
36+
37+
split_dn = dn.split(r'.')
38+
leftmost, remainder = split_dn[0], split_dn[1:]
39+
40+
wildcards = leftmost.count('*')
41+
if wildcards > max_wildcards:
42+
# Issue #17980: avoid denials of service by refusing more
43+
# than one wildcard per fragment. A survey of established
44+
# policy among SSL implementations showed it to be a
45+
# reasonable choice.
46+
raise CertificateError(
47+
"too many wildcards in certificate DNS name: " + repr(dn))
48+
49+
# speed up common case w/o wildcards
50+
if not wildcards:
51+
return dn.lower() == hostname.lower()
52+
53+
# RFC 6125, section 6.4.3, subitem 1.
54+
# The client SHOULD NOT attempt to match a presented identifier in which
55+
# the wildcard character comprises a label other than the left-most label.
56+
if leftmost == '*':
57+
# When '*' is a fragment by itself, it matches a non-empty dotless
58+
# fragment.
59+
pats.append('[^.]+')
60+
elif leftmost.startswith('xn--') or hostname.startswith('xn--'):
61+
# RFC 6125, section 6.4.3, subitem 3.
62+
# The client SHOULD NOT attempt to match a presented identifier
63+
# where the wildcard character is embedded within an A-label or
64+
# U-label of an internationalized domain name.
65+
pats.append(re.escape(leftmost))
66+
else:
67+
# Otherwise, '*' matches any dotless string, e.g. www*
68+
pats.append(re.escape(leftmost).replace(r'\*', '[^.]*'))
69+
70+
# add the remaining fragments, ignore any wildcards
71+
for frag in remainder:
72+
pats.append(re.escape(frag))
73+
74+
pat = re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE)
75+
return pat.match(hostname)
76+
77+
78+
def match_hostname(cert, hostname):
79+
"""Verify that *cert* (in decoded format as returned by
80+
SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125
81+
rules are followed, but IP addresses are not accepted for *hostname*.
82+
83+
CertificateError is raised on failure. On success, the function
84+
returns nothing.
85+
"""
86+
if not cert:
87+
raise ValueError("empty or no certificate, match_hostname needs a "
88+
"SSL socket or SSL context with either "
89+
"CERT_OPTIONAL or CERT_REQUIRED")
90+
try:
91+
host_ip = ipaddress.ip_address(six.text_type(hostname))
92+
except ValueError:
93+
# Not an IP address (common case)
94+
host_ip = None
95+
dnsnames = []
96+
san = cert.get('subjectAltName', ())
97+
for key, value in san:
98+
if key == 'DNS':
99+
if host_ip is None and _dnsname_match(value, hostname):
100+
return
101+
dnsnames.append(value)
102+
elif key == 'IP Address':
103+
if host_ip is not None and _ipaddress_match(value, host_ip):
104+
return
105+
dnsnames.append(value)
106+
if not dnsnames:
107+
# The subject is only checked when there is no dNSName entry
108+
# in subjectAltName
109+
for sub in cert.get('subject', ()):
110+
for key, value in sub:
111+
# XXX according to RFC 2818, the most specific Common Name
112+
# must be used.
113+
if key == 'commonName':
114+
if _dnsname_match(value, hostname):
115+
return
116+
dnsnames.append(value)
117+
if len(dnsnames) > 1:
118+
raise CertificateError(
119+
"hostname %r doesn't match either of %s"
120+
% (hostname, ', '.join(map(repr, dnsnames))))
121+
elif len(dnsnames) == 1:
122+
raise CertificateError(
123+
"hostname %r doesn't match %r"
124+
% (hostname, dnsnames[0])
125+
)
126+
else:
127+
raise CertificateError(
128+
"no appropriate commonName or "
129+
"subjectAltName fields were found"
130+
)

docker/ssladapter/ssladapter.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/
33
https://github.com/kennethreitz/requests/pull/799
44
"""
5+
import sys
6+
57
from distutils.version import StrictVersion
68
from requests.adapters import HTTPAdapter
79

@@ -10,8 +12,15 @@
1012
except ImportError:
1113
import urllib3
1214

15+
1316
PoolManager = urllib3.poolmanager.PoolManager
1417

18+
# Monkey-patching match_hostname with a version that supports
19+
# IP-address checking. Not necessary for Python 3.5 and above
20+
if sys.version_info[0] < 3 or sys.version_info[1] < 5:
21+
from .ssl_match_hostname import match_hostname
22+
urllib3.connection.match_hostname = match_hostname
23+
1524

1625
class SSLAdapter(HTTPAdapter):
1726
'''An HTTPS Transport Adapter that uses an arbitrary SSL version.'''

docker/unixconn/unixconn.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
class UnixHTTPConnection(httplib.HTTPConnection, object):
3232
def __init__(self, base_url, unix_socket, timeout=60):
3333
super(UnixHTTPConnection, self).__init__(
34-
'localhost', timeout=timeout)
34+
'localhost', timeout=timeout
35+
)
3536
self.base_url = base_url
3637
self.unix_socket = unix_socket
3738
self.timeout = timeout
@@ -46,7 +47,8 @@ def connect(self):
4647
class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
4748
def __init__(self, base_url, socket_path, timeout=60):
4849
super(UnixHTTPConnectionPool, self).__init__(
49-
'localhost', timeout=timeout)
50+
'localhost', timeout=timeout
51+
)
5052
self.base_url = base_url
5153
self.socket_path = socket_path
5254
self.timeout = timeout

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
requests==2.5.3
22
six>=1.4.0
33
websocket-client==0.32.0
4+
py2-ipaddress==3.4.1

requirements3.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
requests==2.5.3
2+
six>=1.4.0
3+
websocket-client==0.32.0

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
'websocket-client >= 0.32.0',
1313
]
1414

15+
if sys.version_info[0] == 2:
16+
requirements.append('py2-ipaddress >= 3.4.1')
17+
1518
exec(open('docker/version.py').read())
1619

1720
with open('./test-requirements.txt') as test_reqs_txt:

tests/unit/ssladapter_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from docker.ssladapter import ssladapter
2+
from docker.ssladapter.ssl_match_hostname import (
3+
match_hostname, CertificateError
4+
)
5+
6+
try:
7+
from ssl import OP_NO_SSLv3, OP_NO_SSLv2, OP_NO_TLSv1
8+
except ImportError:
9+
OP_NO_SSLv2 = 0x1000000
10+
OP_NO_SSLv3 = 0x2000000
11+
OP_NO_TLSv1 = 0x4000000
12+
13+
from .. import base
14+
15+
16+
class SSLAdapterTest(base.BaseTestCase):
17+
def test_only_uses_tls(self):
18+
ssl_context = ssladapter.urllib3.util.ssl_.create_urllib3_context()
19+
20+
assert ssl_context.options & OP_NO_SSLv3
21+
assert ssl_context.options & OP_NO_SSLv2
22+
assert not ssl_context.options & OP_NO_TLSv1
23+
24+
25+
class MatchHostnameTest(base.BaseTestCase):
26+
cert = {
27+
'issuer': (
28+
(('countryName', u'US'),),
29+
(('stateOrProvinceName', u'California'),),
30+
(('localityName', u'San Francisco'),),
31+
(('organizationName', u'Docker Inc'),),
32+
(('organizationalUnitName', u'Docker-Python'),),
33+
(('commonName', u'localhost'),),
34+
(('emailAddress', u'[email protected]'),)
35+
),
36+
'notAfter': 'Mar 25 23:08:23 2030 GMT',
37+
'notBefore': u'Mar 25 23:08:23 2016 GMT',
38+
'serialNumber': u'BD5F894C839C548F',
39+
'subject': (
40+
(('countryName', u'US'),),
41+
(('stateOrProvinceName', u'California'),),
42+
(('localityName', u'San Francisco'),),
43+
(('organizationName', u'Docker Inc'),),
44+
(('organizationalUnitName', u'Docker-Python'),),
45+
(('commonName', u'localhost'),),
46+
(('emailAddress', u'[email protected]'),)
47+
),
48+
'subjectAltName': (
49+
('DNS', u'localhost'),
50+
('DNS', u'*.gensokyo.jp'),
51+
('IP Address', u'127.0.0.1'),
52+
),
53+
'version': 3
54+
}
55+
56+
def test_match_ip_address_success(self):
57+
assert match_hostname(self.cert, '127.0.0.1') is None
58+
59+
def test_match_localhost_success(self):
60+
assert match_hostname(self.cert, 'localhost') is None
61+
62+
def test_match_dns_success(self):
63+
assert match_hostname(self.cert, 'touhou.gensokyo.jp') is None
64+
65+
def test_match_ip_address_failure(self):
66+
self.assertRaises(
67+
CertificateError, match_hostname, self.cert, '192.168.0.25'
68+
)
69+
70+
def test_match_dns_failure(self):
71+
self.assertRaises(
72+
CertificateError, match_hostname, self.cert, 'foobar.co.uk'
73+
)

tests/unit/utils_test.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,9 @@
1212
import pytest
1313
import six
1414

15-
try:
16-
from ssl import OP_NO_SSLv3, OP_NO_SSLv2, OP_NO_TLSv1
17-
except ImportError:
18-
OP_NO_SSLv2 = 0x1000000
19-
OP_NO_SSLv3 = 0x2000000
20-
OP_NO_TLSv1 = 0x4000000
21-
2215
from docker.client import Client
2316
from docker.constants import DEFAULT_DOCKER_API_VERSION
2417
from docker.errors import DockerException, InvalidVersion
25-
from docker.ssladapter import ssladapter
2618
from docker.utils import (
2719
parse_repository_tag, parse_host, convert_filters, kwargs_from_env,
2820
create_host_config, Ulimit, LogConfig, parse_bytes, parse_env_file,
@@ -962,12 +954,3 @@ def test_tar_with_directory_symlinks(self):
962954
self.assertEqual(
963955
sorted(tar_data.getnames()), ['bar', 'bar/foo', 'foo']
964956
)
965-
966-
967-
class SSLAdapterTest(base.BaseTestCase):
968-
def test_only_uses_tls(self):
969-
ssl_context = ssladapter.urllib3.util.ssl_.create_urllib3_context()
970-
971-
assert ssl_context.options & OP_NO_SSLv3
972-
assert ssl_context.options & OP_NO_SSLv2
973-
assert not ssl_context.options & OP_NO_TLSv1

0 commit comments

Comments
 (0)