OLD | NEW |
1 #!/usr/bin/env python | |
2 # | |
3 # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007 Python Software | 1 # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007 Python Software |
4 # Foundation; All Rights Reserved | 2 # Foundation; All Rights Reserved |
5 | 3 |
6 """A HTTPSConnection/Handler with additional proxy and cert validation features. | 4 """A HTTPSConnection/Handler with additional proxy and cert validation features. |
7 | 5 |
8 In particular, monkey patches in Python r74203 to provide support for CONNECT | 6 In particular, monkey patches in Python r74203 to provide support for CONNECT |
9 proxies and adds SSL cert validation if the ssl module is present. | 7 proxies and adds SSL cert validation if the ssl module is present. |
10 """ | 8 """ |
11 | 9 |
12 __author__ = "{frew,nick.johnson}@google.com (Fred Wulff and Nick Johnson)" | 10 __author__ = "{frew,nick.johnson}@google.com (Fred Wulff and Nick Johnson)" |
13 | 11 |
14 import base64 | 12 import base64 |
15 import httplib | 13 import httplib |
16 import logging | 14 import logging |
17 import re | |
18 import socket | 15 import socket |
| 16 from urllib import splitpasswd |
| 17 from urllib import splittype |
| 18 from urllib import splituser |
19 import urllib2 | 19 import urllib2 |
20 | 20 |
21 from urllib import splittype | |
22 from urllib import splituser | |
23 from urllib import splitpasswd | |
24 | 21 |
25 class InvalidCertificateException(httplib.HTTPException): | 22 class InvalidCertificateException(httplib.HTTPException): |
26 """Raised when a certificate is provided with an invalid hostname.""" | 23 """Raised when a certificate is provided with an invalid hostname.""" |
27 | 24 |
28 def __init__(self, host, cert, reason): | 25 def __init__(self, host, cert, reason): |
29 """Constructor. | 26 """Constructor. |
30 | 27 |
31 Args: | 28 Args: |
32 host: The hostname the connection was made to. | 29 host: The hostname the connection was made to. |
33 cert: The SSL certificate (as a dictionary) the host returned. | 30 cert: The SSL certificate (as a dictionary) the host returned. |
| 31 reason: user readable error reason. |
34 """ | 32 """ |
35 httplib.HTTPException.__init__(self) | 33 httplib.HTTPException.__init__(self) |
36 self.host = host | 34 self.host = host |
37 self.cert = cert | 35 self.cert = cert |
38 self.reason = reason | 36 self.reason = reason |
39 | 37 |
40 def __str__(self): | 38 def __str__(self): |
41 return ('Host %s returned an invalid certificate (%s): %s\n' | 39 return ("Host %s returned an invalid certificate (%s): %s\n" |
42 'To learn more, see ' | 40 "To learn more, see " |
43 'http://code.google.com/appengine/kb/general.html#rpcssl' % | 41 "http://code.google.com/appengine/kb/general.html#rpcssl" % |
44 (self.host, self.reason, self.cert)) | 42 (self.host, self.reason, self.cert)) |
45 | 43 |
| 44 |
| 45 try: |
| 46 import ssl |
| 47 _CAN_VALIDATE_CERTS = True |
| 48 except ImportError: |
| 49 _CAN_VALIDATE_CERTS = False |
| 50 |
| 51 |
46 def can_validate_certs(): | 52 def can_validate_certs(): |
47 """Return True if we have the SSL package and can validate certificates.""" | 53 """Return True if we have the SSL package and can validate certificates.""" |
48 try: | 54 return _CAN_VALIDATE_CERTS |
49 import ssl | |
50 return True | |
51 except ImportError: | |
52 return False | |
53 | 55 |
54 def _create_fancy_connection(tunnel_host=None, key_file=None, | 56 |
55 cert_file=None, ca_certs=None): | 57 # Reexport SSLError so clients don't have to to do their own checking for ssl's |
| 58 # existence. |
| 59 if can_validate_certs(): |
| 60 SSLError = ssl.SSLError |
| 61 else: |
| 62 SSLError = None |
| 63 |
| 64 |
| 65 def create_fancy_connection(tunnel_host=None, key_file=None, |
| 66 cert_file=None, ca_certs=None, |
| 67 proxy_authorization=None): |
56 # This abomination brought to you by the fact that | 68 # This abomination brought to you by the fact that |
57 # the HTTPHandler creates the connection instance in the middle | 69 # the HTTPHandler creates the connection instance in the middle |
58 # of do_open so we need to add the tunnel host to the class. | 70 # of do_open so we need to add the tunnel host to the class. |
59 | 71 |
60 class PresetProxyHTTPSConnection(httplib.HTTPSConnection): | 72 class PresetProxyHTTPSConnection(httplib.HTTPSConnection): |
61 """An HTTPS connection that uses a proxy defined by the enclosing scope.""" | 73 """An HTTPS connection that uses a proxy defined by the enclosing scope.""" |
62 | 74 |
63 def __init__(self, *args, **kwargs): | 75 def __init__(self, *args, **kwargs): |
64 httplib.HTTPSConnection.__init__(self, *args, **kwargs) | 76 httplib.HTTPSConnection.__init__(self, *args, **kwargs) |
65 | 77 |
66 self._tunnel_host = tunnel_host | 78 self._tunnel_host = tunnel_host |
67 if tunnel_host: | 79 if tunnel_host: |
68 logging.debug("Creating preset proxy https conn: %s", tunnel_host) | 80 logging.debug("Creating preset proxy https conn: %s", tunnel_host) |
69 | 81 |
70 self.key_file = key_file | 82 self.key_file = key_file |
71 self.cert_file = cert_file | 83 self.cert_file = cert_file |
72 self.ca_certs = ca_certs | 84 self.ca_certs = ca_certs |
73 try: | 85 if can_validate_certs(): |
74 import ssl | |
75 if self.ca_certs: | 86 if self.ca_certs: |
76 self.cert_reqs = ssl.CERT_REQUIRED | 87 self.cert_reqs = ssl.CERT_REQUIRED |
77 else: | 88 else: |
78 self.cert_reqs = ssl.CERT_NONE | 89 self.cert_reqs = ssl.CERT_NONE |
79 except ImportError: | 90 |
80 pass | 91 def _get_hostport(self, host, port): |
| 92 # Python 2.7.7rc1 (hg r90728:568041fd8090), 3.4.1 and 3.5 rename |
| 93 # _set_hostport to _get_hostport and changes it's functionality. The |
| 94 # Python 2.7.7rc1 version of this method is included here for |
| 95 # compatibility with earlier versions of Python. Without this, HTTPS over |
| 96 # HTTP CONNECT proxies cannot be used. |
| 97 |
| 98 # This method may be removed if compatibility with Python <2.7.7rc1 is not |
| 99 # required. |
| 100 |
| 101 # Python bug: http://bugs.python.org/issue7776 |
| 102 if port is None: |
| 103 i = host.rfind(":") |
| 104 j = host.rfind("]") # ipv6 addresses have [...] |
| 105 if i > j: |
| 106 try: |
| 107 port = int(host[i+1:]) |
| 108 except ValueError: |
| 109 if host[i+1:] == "": # http://foo.com:/ == http://foo.com/ |
| 110 port = self.default_port |
| 111 else: |
| 112 raise httplib.InvalidURL("nonnumeric port: '%s'" % host[i+1:]) |
| 113 host = host[:i] |
| 114 else: |
| 115 port = self.default_port |
| 116 if host and host[0] == "[" and host[-1] == "]": |
| 117 host = host[1:-1] |
| 118 |
| 119 return (host, port) |
81 | 120 |
82 def _tunnel(self): | 121 def _tunnel(self): |
83 self._set_hostport(self._tunnel_host, None) | 122 self.host, self.port = self._get_hostport(self._tunnel_host, None) |
84 logging.info("Connecting through tunnel to: %s:%d", | 123 logging.info("Connecting through tunnel to: %s:%d", |
85 self.host, self.port) | 124 self.host, self.port) |
86 self.send("CONNECT %s:%d HTTP/1.0\r\n\r\n" % (self.host, self.port)) | 125 |
| 126 self.send("CONNECT %s:%d HTTP/1.0\r\n" % (self.host, self.port)) |
| 127 |
| 128 if proxy_authorization: |
| 129 self.send("Proxy-Authorization: %s\r\n" % proxy_authorization) |
| 130 |
| 131 # blank line |
| 132 self.send("\r\n") |
| 133 |
87 response = self.response_class(self.sock, strict=self.strict, | 134 response = self.response_class(self.sock, strict=self.strict, |
88 method=self._method) | 135 method=self._method) |
| 136 # pylint: disable=protected-access |
89 (_, code, message) = response._read_status() | 137 (_, code, message) = response._read_status() |
90 | 138 |
91 if code != 200: | 139 if code != 200: |
92 self.close() | 140 self.close() |
93 raise socket.error, "Tunnel connection failed: %d %s" % ( | 141 raise socket.error("Tunnel connection failed: %d %s" % |
94 code, message.strip()) | 142 (code, message.strip())) |
95 | 143 |
96 while True: | 144 while True: |
97 line = response.fp.readline() | 145 line = response.fp.readline() |
98 if line == "\r\n": | 146 if line == "\r\n": |
99 break | 147 break |
100 | 148 |
101 def _get_valid_hosts_for_cert(self, cert): | 149 def _get_valid_hosts_for_cert(self, cert): |
102 """Returns a list of valid host globs for an SSL certificate. | 150 """Returns a list of valid host globs for an SSL certificate. |
103 | 151 |
104 Args: | 152 Args: |
105 cert: A dictionary representing an SSL certificate. | 153 cert: A dictionary representing an SSL certificate. |
106 Returns: | 154 Returns: |
107 list: A list of valid host globs. | 155 list: A list of valid host globs. |
108 """ | 156 """ |
109 if 'subjectAltName' in cert: | 157 if "subjectAltName" in cert: |
110 return [x[1] for x in cert['subjectAltName'] if x[0].lower() == 'dns'] | 158 return [x[1] for x in cert["subjectAltName"] if x[0].lower() == "dns"] |
111 else: | 159 else: |
112 # Return a list of commonName fields | 160 # Return a list of commonName fields |
113 return [x[0][1] for x in cert['subject'] | 161 return [x[0][1] for x in cert["subject"] |
114 if x[0][0].lower() == 'commonname'] | 162 if x[0][0].lower() == "commonname"] |
115 | 163 |
116 def _validate_certificate_hostname(self, cert, hostname): | 164 def _validate_certificate_hostname(self, cert, hostname): |
117 """Validates that a given hostname is valid for an SSL certificate. | 165 """Perform RFC2818/6125 validation against a cert and hostname. |
118 | 166 |
119 Args: | 167 Args: |
120 cert: A dictionary representing an SSL certificate. | 168 cert: A dictionary representing an SSL certificate. |
121 hostname: The hostname to test. | 169 hostname: The hostname to test. |
122 Returns: | 170 Returns: |
123 bool: Whether or not the hostname is valid for this certificate. | 171 bool: Whether or not the hostname is valid for this certificate. |
124 """ | 172 """ |
125 hosts = self._get_valid_hosts_for_cert(cert) | 173 hosts = self._get_valid_hosts_for_cert(cert) |
126 for host in hosts: | 174 for host in hosts: |
127 # Convert the glob-style hostname expression (eg, '*.google.com') into a | 175 # Wildcards are only valid when the * exists at the end of the last |
128 # valid regular expression. | 176 # (left-most) label, and there are at least 3 labels in the expression. |
129 host_re = host.replace('.', '\.').replace('*', '[^.]*') | 177 if ("*." in host and host.count("*") == 1 and |
130 if re.search('^%s$' % (host_re,), hostname, re.I): | 178 host.count(".") > 1 and "." in hostname): |
| 179 left_expected, right_expected = host.split("*.") |
| 180 left_hostname, right_hostname = hostname.split(".", 1) |
| 181 if (left_hostname.startswith(left_expected) and |
| 182 right_expected == right_hostname): |
| 183 return True |
| 184 elif host == hostname: |
131 return True | 185 return True |
132 return False | 186 return False |
133 | 187 |
134 | |
135 def connect(self): | 188 def connect(self): |
136 # TODO(frew): When we drop support for <2.6 (in the far distant future), | 189 # TODO(frew): When we drop support for <2.6 (in the far distant future), |
137 # change this to socket.create_connection. | 190 # change this to socket.create_connection. |
138 self.sock = _create_connection((self.host, self.port)) | 191 self.sock = _create_connection((self.host, self.port)) |
139 | 192 |
140 if self._tunnel_host: | 193 if self._tunnel_host: |
141 self._tunnel() | 194 self._tunnel() |
142 | 195 |
143 # ssl and FakeSocket got deprecated. Try for the new hotness of wrap_ssl, | 196 # ssl and FakeSocket got deprecated. Try for the new hotness of wrap_ssl, |
144 # with fallback. | 197 # with fallback. Note: Since can_validate_certs() just checks for the |
145 try: | 198 # ssl module, it's equivalent to attempting to import ssl from |
146 import ssl | 199 # the function, but doesn't require a dynamic import, which doesn't |
| 200 # play nicely with dev_appserver. |
| 201 if can_validate_certs(): |
147 self.sock = ssl.wrap_socket(self.sock, | 202 self.sock = ssl.wrap_socket(self.sock, |
148 keyfile=self.key_file, | 203 keyfile=self.key_file, |
149 certfile=self.cert_file, | 204 certfile=self.cert_file, |
150 ca_certs=self.ca_certs, | 205 ca_certs=self.ca_certs, |
151 cert_reqs=self.cert_reqs) | 206 cert_reqs=self.cert_reqs) |
152 | 207 |
153 if self.cert_reqs & ssl.CERT_REQUIRED: | 208 if self.cert_reqs & ssl.CERT_REQUIRED: |
154 cert = self.sock.getpeercert() | 209 cert = self.sock.getpeercert() |
155 hostname = self.host.split(':', 0)[0] | 210 hostname = self.host.split(":", 0)[0] |
156 if not self._validate_certificate_hostname(cert, hostname): | 211 if not self._validate_certificate_hostname(cert, hostname): |
157 raise InvalidCertificateException(hostname, cert, | 212 raise InvalidCertificateException(hostname, cert, |
158 'hostname mismatch') | 213 "hostname mismatch") |
159 except ImportError: | 214 else: |
160 ssl = socket.ssl(self.sock, | 215 ssl_socket = socket.ssl(self.sock, |
161 keyfile=self.key_file, | 216 keyfile=self.key_file, |
162 certfile=self.cert_file) | 217 certfile=self.cert_file) |
163 self.sock = httplib.FakeSocket(self.sock, ssl) | 218 self.sock = httplib.FakeSocket(self.sock, ssl_socket) |
164 | 219 |
165 return PresetProxyHTTPSConnection | 220 return PresetProxyHTTPSConnection |
166 | 221 |
167 | 222 |
168 # Here to end of _create_connection copied wholesale from Python 2.6"s socket.py | 223 # Here to end of _create_connection copied wholesale from Python 2.6"s socket.py |
169 _GLOBAL_DEFAULT_TIMEOUT = object() | 224 _GLOBAL_DEFAULT_TIMEOUT = object() |
170 | 225 |
171 | 226 |
172 def _create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT): | 227 def _create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT): |
173 """Connect to *address* and return the socket object. | 228 """Connect to *address* and return the socket object. |
(...skipping 148 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
322 # This condition is the change | 377 # This condition is the change |
323 if orig_type == "https": | 378 if orig_type == "https": |
324 return None | 379 return None |
325 | 380 |
326 return urllib2.ProxyHandler.proxy_open(self, req, proxy, type) | 381 return urllib2.ProxyHandler.proxy_open(self, req, proxy, type) |
327 | 382 |
328 | 383 |
329 class FancyHTTPSHandler(urllib2.HTTPSHandler): | 384 class FancyHTTPSHandler(urllib2.HTTPSHandler): |
330 """An HTTPSHandler that works with CONNECT-enabled proxies.""" | 385 """An HTTPSHandler that works with CONNECT-enabled proxies.""" |
331 | 386 |
332 def do_open(self, http_class, req): | 387 def do_open(self, http_class, req, *args, **kwargs): |
| 388 proxy_authorization = None |
| 389 for header in req.headers: |
| 390 if header.lower() == "proxy-authorization": |
| 391 proxy_authorization = req.headers[header] |
| 392 break |
| 393 |
333 # Intentionally very specific so as to opt for false negatives | 394 # Intentionally very specific so as to opt for false negatives |
334 # rather than false positives. | 395 # rather than false positives. |
335 try: | 396 try: |
336 return urllib2.HTTPSHandler.do_open( | 397 return urllib2.HTTPSHandler.do_open( |
337 self, | 398 self, |
338 _create_fancy_connection(req._tunnel_host, | 399 create_fancy_connection(req._tunnel_host, |
339 req._key_file, | 400 req._key_file, |
340 req._cert_file, | 401 req._cert_file, |
341 req._ca_certs), | 402 req._ca_certs, |
342 req) | 403 proxy_authorization), |
| 404 req, *args, **kwargs) |
343 except urllib2.URLError, url_error: | 405 except urllib2.URLError, url_error: |
344 try: | 406 try: |
345 import ssl | 407 import ssl |
346 if (type(url_error.reason) == ssl.SSLError and | 408 if (type(url_error.reason) == ssl.SSLError and |
347 url_error.reason.args[0] == 1): | 409 url_error.reason.args[0] == 1): |
348 # Display the reason to the user. Need to use args for python2.5 | 410 # Display the reason to the user. Need to use args for python2.5 |
349 # compat. | 411 # compat. |
350 raise InvalidCertificateException(req.host, '', | 412 raise InvalidCertificateException(req.host, "", |
351 url_error.reason.args[1]) | 413 url_error.reason.args[1]) |
352 except ImportError: | 414 except ImportError: |
353 pass | 415 pass |
354 | 416 |
355 raise url_error | 417 raise url_error |
356 | 418 |
357 | 419 |
358 # We have to implement this so that we persist the tunneling behavior | 420 # We have to implement this so that we persist the tunneling behavior |
359 # through redirects. | 421 # through redirects. |
360 class FancyRedirectHandler(urllib2.HTTPRedirectHandler): | 422 class FancyRedirectHandler(urllib2.HTTPRedirectHandler): |
(...skipping 28 matching lines...) Expand all Loading... |
389 # req is not proxied, so just make sure _tunnel_host is defined. | 451 # req is not proxied, so just make sure _tunnel_host is defined. |
390 new_req._tunnel_host = None | 452 new_req._tunnel_host = None |
391 new_req.type = "https" | 453 new_req.type = "https" |
392 if hasattr(req, "_key_file") and isinstance(new_req, urllib2.Request): | 454 if hasattr(req, "_key_file") and isinstance(new_req, urllib2.Request): |
393 # Copy the auxiliary data in case this or any further redirect is https | 455 # Copy the auxiliary data in case this or any further redirect is https |
394 new_req._key_file = req._key_file | 456 new_req._key_file = req._key_file |
395 new_req._cert_file = req._cert_file | 457 new_req._cert_file = req._cert_file |
396 new_req._ca_certs = req._ca_certs | 458 new_req._ca_certs = req._ca_certs |
397 | 459 |
398 return new_req | 460 return new_req |
OLD | NEW |