Merge pull request #11589 from ansible/get_url-sni-tls-fix
Add support for SNI and TLS-1.1 and TLS-1.2 to the fetch_url() helper
This commit is contained in:
commit
3e293f524a
3 changed files with 97 additions and 17 deletions
|
@ -95,9 +95,16 @@ except:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import ssl
|
import ssl
|
||||||
HAS_SSL=True
|
HAS_SSL = True
|
||||||
except:
|
except:
|
||||||
HAS_SSL=False
|
HAS_SSL = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# SNI Handling needs python2.7.9's SSLContext
|
||||||
|
from ssl import create_default_context, SSLContext
|
||||||
|
HAS_SSLCONTEXT = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_SSLCONTEXT = False
|
||||||
|
|
||||||
HAS_MATCH_HOSTNAME = True
|
HAS_MATCH_HOSTNAME = True
|
||||||
try:
|
try:
|
||||||
|
@ -277,6 +284,13 @@ class NoSSLError(SSLValidationError):
|
||||||
|
|
||||||
|
|
||||||
class CustomHTTPSConnection(httplib.HTTPSConnection):
|
class CustomHTTPSConnection(httplib.HTTPSConnection):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
httplib.HTTPSConnection.__init__(self, *args, **kwargs)
|
||||||
|
if HAS_SSLCONTEXT:
|
||||||
|
self.context = create_default_context()
|
||||||
|
if self.cert_file:
|
||||||
|
self.context.load_cert_chain(self.cert_file, self.key_file)
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"Connect to a host on a given (SSL) port."
|
"Connect to a host on a given (SSL) port."
|
||||||
|
|
||||||
|
@ -287,7 +301,10 @@ class CustomHTTPSConnection(httplib.HTTPSConnection):
|
||||||
if self._tunnel_host:
|
if self._tunnel_host:
|
||||||
self.sock = sock
|
self.sock = sock
|
||||||
self._tunnel()
|
self._tunnel()
|
||||||
self.sock = ssl.wrap_socket(sock, keyfile=self.key_file, certfile=self.cert_file, ssl_version=ssl.PROTOCOL_TLSv1)
|
if HAS_SSLCONTEXT:
|
||||||
|
self.sock = self.context.wrap_socket(sock, server_hostname=self.host)
|
||||||
|
else:
|
||||||
|
self.sock = ssl.wrap_socket(sock, keyfile=self.key_file, certfile=self.cert_file, ssl_version=ssl.PROTOCOL_TLSv1)
|
||||||
|
|
||||||
class CustomHTTPSHandler(urllib2.HTTPSHandler):
|
class CustomHTTPSHandler(urllib2.HTTPSHandler):
|
||||||
|
|
||||||
|
@ -462,9 +479,17 @@ class SSLValidationHandler(urllib2.BaseHandler):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _make_context(self, tmp_ca_cert_path):
|
||||||
|
context = create_default_context()
|
||||||
|
context.load_verify_locations(tmp_ca_cert_path)
|
||||||
|
return context
|
||||||
|
|
||||||
def http_request(self, req):
|
def http_request(self, req):
|
||||||
tmp_ca_cert_path, paths_checked = self.get_ca_certs()
|
tmp_ca_cert_path, paths_checked = self.get_ca_certs()
|
||||||
https_proxy = os.environ.get('https_proxy')
|
https_proxy = os.environ.get('https_proxy')
|
||||||
|
context = None
|
||||||
|
if HAS_SSLCONTEXT:
|
||||||
|
context = self._make_context(tmp_ca_cert_path)
|
||||||
|
|
||||||
# Detect if 'no_proxy' environment variable is set and if our URL is included
|
# Detect if 'no_proxy' environment variable is set and if our URL is included
|
||||||
use_proxy = self.detect_no_proxy(req.get_full_url())
|
use_proxy = self.detect_no_proxy(req.get_full_url())
|
||||||
|
@ -486,14 +511,20 @@ class SSLValidationHandler(urllib2.BaseHandler):
|
||||||
s.sendall('\r\n')
|
s.sendall('\r\n')
|
||||||
connect_result = s.recv(4096)
|
connect_result = s.recv(4096)
|
||||||
self.validate_proxy_response(connect_result)
|
self.validate_proxy_response(connect_result)
|
||||||
ssl_s = ssl.wrap_socket(s, ca_certs=tmp_ca_cert_path, cert_reqs=ssl.CERT_REQUIRED)
|
if context:
|
||||||
match_hostname(ssl_s.getpeercert(), self.hostname)
|
ssl_s = context.wrap_socket(s, server_hostname=proxy_parts.get('hostname'))
|
||||||
|
else:
|
||||||
|
ssl_s = ssl.wrap_socket(s, ca_certs=tmp_ca_cert_path, cert_reqs=ssl.CERT_REQUIRED, ssl_version=ssl.PROTOCOL_TLSv1)
|
||||||
|
match_hostname(ssl_s.getpeercert(), self.hostname)
|
||||||
else:
|
else:
|
||||||
raise ProxyError('Unsupported proxy scheme: %s. Currently ansible only supports HTTP proxies.' % proxy_parts.get('scheme'))
|
raise ProxyError('Unsupported proxy scheme: %s. Currently ansible only supports HTTP proxies.' % proxy_parts.get('scheme'))
|
||||||
else:
|
else:
|
||||||
s.connect((self.hostname, self.port))
|
s.connect((self.hostname, self.port))
|
||||||
ssl_s = ssl.wrap_socket(s, ca_certs=tmp_ca_cert_path, cert_reqs=ssl.CERT_REQUIRED)
|
if context:
|
||||||
match_hostname(ssl_s.getpeercert(), self.hostname)
|
ssl_s = context.wrap_socket(s, server_hostname=self.hostname)
|
||||||
|
else:
|
||||||
|
ssl_s = ssl.wrap_socket(s, ca_certs=tmp_ca_cert_path, cert_reqs=ssl.CERT_REQUIRED, ssl_version=ssl.PROTOCOL_TLSv1)
|
||||||
|
match_hostname(ssl_s.getpeercert(), self.hostname)
|
||||||
# close the ssl connection
|
# close the ssl connection
|
||||||
#ssl_s.unwrap()
|
#ssl_s.unwrap()
|
||||||
s.close()
|
s.close()
|
||||||
|
@ -502,9 +533,14 @@ class SSLValidationHandler(urllib2.BaseHandler):
|
||||||
if 'connection refused' in str(e).lower():
|
if 'connection refused' in str(e).lower():
|
||||||
raise ConnectionError('Failed to connect to %s:%s.' % (self.hostname, self.port))
|
raise ConnectionError('Failed to connect to %s:%s.' % (self.hostname, self.port))
|
||||||
else:
|
else:
|
||||||
raise SSLValidationError('Failed to validate the SSL certificate for %s:%s. '
|
raise SSLValidationError('Failed to validate the SSL certificate for %s:%s.'
|
||||||
'Use validate_certs=False (insecure) or make sure your managed systems have a valid CA certificate installed. '
|
' Make sure your managed systems have a valid CA'
|
||||||
'Paths checked for this platform: %s' % (self.hostname, self.port, ", ".join(paths_checked))
|
' certificate installed. If the website serving the url'
|
||||||
|
' uses SNI you need python >= 2.7.9 on your managed'
|
||||||
|
' machine. You can use validate_certs=False if you do'
|
||||||
|
' not need to confirm the server\s identity but this is'
|
||||||
|
' unsafe and not recommended'
|
||||||
|
' Paths checked for this platform: %s' % (self.hostname, self.port, ", ".join(paths_checked))
|
||||||
)
|
)
|
||||||
except CertificateError:
|
except CertificateError:
|
||||||
raise SSLValidationError("SSL Certificate does not belong to %s. Make sure the url has a certificate that belongs to it or use validate_certs=False (insecure)" % self.hostname)
|
raise SSLValidationError("SSL Certificate does not belong to %s. Make sure the url has a certificate that belongs to it or use validate_certs=False (insecure)" % self.hostname)
|
||||||
|
@ -534,8 +570,6 @@ def open_url(url, data=None, headers=None, method=None, use_proxy=True,
|
||||||
if parsed[0] == 'https' and validate_certs:
|
if parsed[0] == 'https' and validate_certs:
|
||||||
if not HAS_SSL:
|
if not HAS_SSL:
|
||||||
raise NoSSLError('SSL validation is not available in your version of python. You can use validate_certs=False, however this is unsafe and not recommended')
|
raise NoSSLError('SSL validation is not available in your version of python. You can use validate_certs=False, however this is unsafe and not recommended')
|
||||||
if not HAS_MATCH_HOSTNAME:
|
|
||||||
raise SSLValidationError('Available SSL validation does not check that the certificate matches the hostname. You can install backports.ssl_match_hostname or update your managed machine to python-2.7.9 or newer. You could also use validate_certs=False, however this is unsafe and not recommended')
|
|
||||||
|
|
||||||
# do the cert validation
|
# do the cert validation
|
||||||
netloc = parsed[1]
|
netloc = parsed[1]
|
||||||
|
@ -630,13 +664,22 @@ def open_url(url, data=None, headers=None, method=None, use_proxy=True,
|
||||||
for header in headers:
|
for header in headers:
|
||||||
request.add_header(header, headers[header])
|
request.add_header(header, headers[header])
|
||||||
|
|
||||||
if sys.version_info < (2,6,0):
|
urlopen_args = [request, None]
|
||||||
|
if sys.version_info >= (2,6,0):
|
||||||
# urlopen in python prior to 2.6.0 did not
|
# urlopen in python prior to 2.6.0 did not
|
||||||
# have a timeout parameter
|
# have a timeout parameter
|
||||||
r = urllib2.urlopen(request, None)
|
urlopen_args.append(timeout)
|
||||||
else:
|
|
||||||
r = urllib2.urlopen(request, None, timeout)
|
|
||||||
|
|
||||||
|
if HAS_SSLCONTEXT and not validate_certs:
|
||||||
|
# In 2.7.9, the default context validates certificates
|
||||||
|
context = SSLContext(ssl.PROTOCOL_SSLv23)
|
||||||
|
context.options |= ssl.OP_NO_SSLv2
|
||||||
|
context.options |= ssl.OP_NO_SSLv3
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
context.check_hostname = False
|
||||||
|
urlopen_args += (None, None, None, context)
|
||||||
|
|
||||||
|
r = urllib2.urlopen(*urlopen_args)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
|
@ -60,3 +60,35 @@
|
||||||
that:
|
that:
|
||||||
- "result.changed == true"
|
- "result.changed == true"
|
||||||
- "stat_result.stat.exists == true"
|
- "stat_result.stat.exists == true"
|
||||||
|
|
||||||
|
# SNI Tests
|
||||||
|
# SNI is only built into the stdlib from python-2.7.9 onwards
|
||||||
|
- name: Test that SNI works
|
||||||
|
get_url:
|
||||||
|
# A test site that returns a page with information on what SNI information
|
||||||
|
# the client sent. A failure would have the string: did not send a TLS server name indication extension
|
||||||
|
url: 'https://foo.sni.velox.ch/'
|
||||||
|
dest: "{{ output_dir }}/sni.html"
|
||||||
|
register: get_url_result
|
||||||
|
ignore_errors: True
|
||||||
|
|
||||||
|
- command: "grep 'sent the following TLS server name indication extension' {{ output_dir}}/sni.html"
|
||||||
|
register: data_result
|
||||||
|
when: "{{ ansible_python_version | version_compare('2.7.9', '>=') }}"
|
||||||
|
|
||||||
|
# If distros start backporting SNI, can make a new conditional based on whether this works:
|
||||||
|
# python -c 'from ssl import SSLContext'
|
||||||
|
- debug: msg=get_url_result
|
||||||
|
- name: Assert that SNI works with this python version
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- 'data_result.rc == 0'
|
||||||
|
- '"failed" not in get_url_result'
|
||||||
|
when: "{{ ansible_python_version | version_compare('2.7.9', '>=') }}"
|
||||||
|
|
||||||
|
# If the client doesn't support SNI then get_url should have failed with a certificate mismatch
|
||||||
|
- name: Assert that hostname verification failed because SNI is not supported on this version of python
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- 'get_url_result["failed"]'
|
||||||
|
when: "{{ ansible_python_version | version_compare('2.7.9', '<') }}"
|
||||||
|
|
|
@ -110,6 +110,11 @@
|
||||||
- "'certificate does not match ' in result.msg"
|
- "'certificate does not match ' in result.msg"
|
||||||
- "stat_result.stat.exists == false"
|
- "stat_result.stat.exists == false"
|
||||||
|
|
||||||
|
- name: Clean up any cruft from the results directory
|
||||||
|
file:
|
||||||
|
name: "{{ output_dir }}/kreitz.html"
|
||||||
|
state: absent
|
||||||
|
|
||||||
- name: test https fetch to a site with mismatched hostname and certificate and validate_certs=no
|
- name: test https fetch to a site with mismatched hostname and certificate and validate_certs=no
|
||||||
get_url:
|
get_url:
|
||||||
url: "https://kennethreitz.org/"
|
url: "https://kennethreitz.org/"
|
||||||
|
@ -124,5 +129,5 @@
|
||||||
- name: Assert that the file was downloaded
|
- name: Assert that the file was downloaded
|
||||||
assert:
|
assert:
|
||||||
that:
|
that:
|
||||||
- "result.changed == true"
|
|
||||||
- "stat_result.stat.exists == true"
|
- "stat_result.stat.exists == true"
|
||||||
|
- "result.changed == true"
|
||||||
|
|
Loading…
Reference in a new issue