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:
Toshio Kuratomi 2015-07-14 12:49:37 -07:00
commit 3e293f524a
3 changed files with 97 additions and 17 deletions

View file

@ -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
# #

View file

@ -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', '<') }}"

View file

@ -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"