Skip to content

Commit 3e72b44

Browse files
author
KP
committed
#34265 add handling for Python versions incompatible with SHA-2
The hosted Shotgun server certificates are being upgraded to more secure ones signed with SHA-2. Some older versions of Python will have issues with this change as they do not support SHA-2 encryption. In order to try and prevent scripts from breaking, when the API encounters a version of Python that is incompatible with SHA-2, it will automatically turn off certificate verification and try the request again. If the validation still fails for some reason, the error will be raised, otherwise the request succeeds and validation will remain off for the remaining life of the connection. There is also support for the `SHOTGUN_FORCE_CERTIFICATE_VALIDATION` environment variable which when set (the value does not matter), will prevent disabling certificate verification and will instead raise an exception. This behavior of having certificate validation off, is actually the default in Python versions < v2.7.9. Up to this point we have been electing to enhance the default level of security. Your connection is still encrypted when certificate validation is off, but the server identity cannot be verified. Adds info showing the OpenSSL version (if available) and whether certificate validation is enabled or not, to the user-agent string
1 parent 9bd65d4 commit 3e72b44

File tree

6 files changed

+156
-27
lines changed

6 files changed

+156
-27
lines changed

shotgun_api3/sg_24.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
import logging
44

5-
from shotgun_api3.lib.httplib2 import Http, ProxyInfo, socks
5+
from shotgun_api3.lib.httplib2 import Http, ProxyInfo, socks, SSLHandshakeError
66
from shotgun_api3.lib.sgtimezone import SgTimezone
77
from shotgun_api3.lib.xmlrpclib import Error, ProtocolError, ResponseError
88
import mimetypes # used for attachment upload

shotgun_api3/sg_25.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import logging
44

5-
from .lib.httplib2 import Http, ProxyInfo, socks
5+
from .lib.httplib2 import Http, ProxyInfo, socks, SSLHandshakeError
66
from .lib.sgtimezone import SgTimezone
77
from .lib.xmlrpclib import Error, ProtocolError, ResponseError
88
import mimetypes # used for attachment upload

shotgun_api3/sg_26.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import logging
44

5-
from .lib.httplib2 import Http, ProxyInfo, socks
5+
from .lib.httplib2 import Http, ProxyInfo, socks, SSLHandshakeError
66
from .lib.sgtimezone import SgTimezone
77
from .lib.xmlrpclib import Error, ProtocolError, ResponseError
88

shotgun_api3/shotgun.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,19 @@
6666

6767
SG_TIMEZONE = SgTimezone()
6868

69-
7069
NO_SSL_VALIDATION = False
7170
try:
72-
import ssl
73-
if os.environ.get("SHOTGUN_DISABLE_SSL_VALIDATION", False):
74-
NO_SSL_VALIDATION = True
75-
except ImportError:
71+
import ssl
72+
except ImportError, e:
73+
if "SHOTGUN_FORCE_CERTIFICATE_VALIDATION" in os.environ:
74+
raise ImportError("%s. SHOTGUN_FORCE_CERTIFICATE_VALIDATION environment variable prevents "
75+
"disabling SSL certificate validation." % e)
7676
LOG.debug("ssl not found, disabling certificate validation")
7777
NO_SSL_VALIDATION = True
7878

7979
# ----------------------------------------------------------------------------
8080
# Version
81-
__version__ = "3.0.24"
81+
__version__ = "3.0.25.Dev"
8282

8383
# ----------------------------------------------------------------------------
8484
# Errors
@@ -217,10 +217,18 @@ def __init__(self):
217217

218218
self.py_version = ".".join(str(x) for x in sys.version_info[:2])
219219

220+
# extract the OpenSSL version if we can. The version is only available in Python 2.7 and
221+
# only if we successfully imported ssl
222+
self.ssl_version = "unknown"
223+
try:
224+
self.ssl_version = ssl.OPENSSL_VERSION
225+
except (AttributeError, NameError):
226+
pass
227+
220228
def __str__(self):
221229
return "ClientCapabilities: platform %s, local_path_field %s, "\
222-
"py_verison %s" % (self.platform, self.local_path_field,
223-
self.py_version)
230+
"py_verison %s, ssl version %s" % (self.platform, self.local_path_field,
231+
self.py_version, self.ssl_version)
224232

225233
class _Config(object):
226234
"""Container for the client configuration."""
@@ -1259,13 +1267,22 @@ def add_user_agent(self, agent):
12591267
def reset_user_agent(self):
12601268
"""Reset user agent to the default.
12611269
1262-
Eg. shotgun-json (3.0.17); Python 2.6 (Mac)
1270+
Eg. "shotgun-json (3.0.17); Python 2.6 (Mac); ssl OpenSSL 1.0.2d 9 Jul 2015 (validate)"
12631271
"""
12641272
ua_platform = "Unknown"
12651273
if self.client_caps.platform is not None:
12661274
ua_platform = self.client_caps.platform.capitalize()
1275+
1276+
1277+
# create ssl validation string based on settings
1278+
validation_str = "validate"
1279+
if self.config.no_ssl_validation:
1280+
validation_str = "no-validate"
1281+
12671282
self._user_agents = ["shotgun-json (%s)" % __version__,
1268-
"Python %s (%s)" % (self.client_caps.py_version, ua_platform)]
1283+
"Python %s (%s)" % (self.client_caps.py_version, ua_platform),
1284+
"ssl %s (%s)" % (self.client_caps.ssl_version, validation_str)]
1285+
12691286

12701287
def set_session_uuid(self, session_uuid):
12711288
"""Sets the browser session_uuid for this API session.
@@ -2000,6 +2017,16 @@ def _build_opener(self, handler):
20002017
opener = urllib2.build_opener(handler)
20012018
return opener
20022019

2020+
def _turn_off_ssl_validation(self):
2021+
"""Turn off SSL certificate validation."""
2022+
global NO_SSL_VALIDATION
2023+
self.config.no_ssl_validation = True
2024+
NO_SSL_VALIDATION = True
2025+
# reset ssl-validation in user-agents
2026+
self._user_agents = ["ssl %s (no-validate)" % self.client_caps.ssl_version
2027+
if ua.startswith("ssl ") else ua
2028+
for ua in self._user_agents]
2029+
20032030
# Deprecated methods from old wrapper
20042031
def schema(self, entity_type):
20052032
raise ShotgunError("Deprecated: use schema_field_read('type':'%s') "
@@ -2149,9 +2176,8 @@ def _make_call(self, verb, path, body, headers):
21492176
"""
21502177

21512178
attempt = 0
2152-
req_headers = {
2153-
"user-agent": "; ".join(self._user_agents),
2154-
}
2179+
req_headers = {}
2180+
req_headers["user-agent"] = "; ".join(self._user_agents)
21552181
if self.config.authorization:
21562182
req_headers["Authorization"] = self.config.authorization
21572183

@@ -2164,6 +2190,38 @@ def _make_call(self, verb, path, body, headers):
21642190
attempt += 1
21652191
try:
21662192
return self._http_request(verb, path, body, req_headers)
2193+
except SSLHandshakeError, e:
2194+
# Test whether the exception is due to the fact that this is an older version of
2195+
# Python that cannot validate certificates encrypted with SHA-2. If it is, then
2196+
# fall back on disabling the certificate validation and try again - unless the
2197+
# SHOTGUN_FORCE_CERTIFICATE_VALIDATION environment variable has been set by the
2198+
# user. In that case we simply raise the exception. Any other exceptions simply
2199+
# get raised as well.
2200+
#
2201+
# For more info see:
2202+
# http://blog.shotgunsoftware.com/2016/01/important-ssl-certificate-renewal-and.html
2203+
#
2204+
# SHA-2 errors look like this:
2205+
# [Errno 1] _ssl.c:480: error:0D0C50A1:asn1 encoding routines:ASN1_item_verify:
2206+
# unknown message digest algorithm
2207+
#
2208+
# Any other exceptions simply get raised.
2209+
if not str(e).endswith("unknown message digest algorithm") or \
2210+
"SHOTGUN_FORCE_CERTIFICATE_VALIDATION" in os.environ:
2211+
raise
2212+
2213+
if self.config.no_ssl_validation is False:
2214+
LOG.warning("SSLHandshakeError: this Python installation is incompatible with "
2215+
"certificates signed with SHA-2. Disabling certificate validation. "
2216+
"For more information, see http://blog.shotgunsoftware.com/2016/01/"
2217+
"important-ssl-certificate-renewal-and.html")
2218+
self._turn_off_ssl_validation()
2219+
# reload user agent to reflect that we have turned off ssl validation
2220+
req_headers["user-agent"] = "; ".join(self._user_agents)
2221+
2222+
self._close_connection()
2223+
if attempt == max_rpc_attempts:
2224+
raise
21672225
except Exception:
21682226
#TODO: LOG ?
21692227
self._close_connection()

tests/test_api.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import urllib2
1616

1717
import shotgun_api3
18-
from shotgun_api3.lib.httplib2 import Http
18+
from shotgun_api3.lib.httplib2 import Http, SSLHandshakeError
1919

2020
import base
2121

@@ -1470,6 +1470,65 @@ def test_status_not_200(self, mock_request):
14701470
mock_request.return_value = (response, {})
14711471
self.assertRaises(shotgun_api3.ProtocolError, self.sg.find_one, 'Shot', [])
14721472

1473+
@patch('shotgun_api3.shotgun.Http.request')
1474+
def test_sha2_error(self, mock_request):
1475+
# Simulate the SSLHandshakeError raised with SHA-2 errors
1476+
mock_request.side_effect = SSLHandshakeError("[Errno 1] _ssl.c:480: error:0D0C50A1:asn1 "
1477+
"encoding routines:ASN1_item_verify: unknown message digest "
1478+
"algorithm")
1479+
1480+
# save the original state
1481+
original_env_val = os.environ.pop("SHOTGUN_FORCE_CERTIFICATE_VALIDATION", None)
1482+
1483+
# ensure we're starting with the right values
1484+
self.sg.reset_user_agent()
1485+
1486+
# ensure the initial settings are correct
1487+
self.assertFalse(self.sg.config.no_ssl_validation)
1488+
self.assertFalse(shotgun_api3.shotgun.NO_SSL_VALIDATION)
1489+
self.assertTrue("(validate)" in " ".join(self.sg._user_agents))
1490+
self.assertFalse("(no-validate)" in " ".join(self.sg._user_agents))
1491+
try:
1492+
result = self.sg.info()
1493+
except SSLHandshakeError:
1494+
# ensure the api has reset the values in the correct fallback behavior
1495+
self.assertTrue(self.sg.config.no_ssl_validation)
1496+
self.assertTrue(shotgun_api3.shotgun.NO_SSL_VALIDATION)
1497+
self.assertFalse("(validate)" in " ".join(self.sg._user_agents))
1498+
self.assertTrue("(no-validate)" in " ".join(self.sg._user_agents))
1499+
1500+
if original_env_val is not None:
1501+
os.environ["SHOTGUN_FORCE_CERTIFICATE_VALIDATION"] = original_env_val
1502+
1503+
@patch('shotgun_api3.shotgun.Http.request')
1504+
def test_sha2_error_with_strict(self, mock_request):
1505+
# Simulate the SSLHandshakeError raised with SHA-2 errors
1506+
mock_request.side_effect = SSLHandshakeError("[Errno 1] _ssl.c:480: error:0D0C50A1:asn1 "
1507+
"encoding routines:ASN1_item_verify: unknown message digest "
1508+
"algorithm")
1509+
1510+
# save the original state
1511+
original_env_val = os.environ.pop("SHOTGUN_FORCE_CERTIFICATE_VALIDATION", None)
1512+
os.environ["SHOTGUN_FORCE_CERTIFICATE_VALIDATION"] = "1"
1513+
1514+
# ensure we're starting with the right values
1515+
self.sg.config.no_ssl_validation = False
1516+
shotgun_api3.shotgun.NO_SSL_VALIDATION = False
1517+
self.sg.reset_user_agent()
1518+
1519+
try:
1520+
result = self.sg.info()
1521+
except SSLHandshakeError:
1522+
# ensure the api has NOT reset the values in the fallback behavior because we have
1523+
# set the env variable to force validation
1524+
self.assertFalse(self.sg.config.no_ssl_validation)
1525+
self.assertFalse(shotgun_api3.shotgun.NO_SSL_VALIDATION)
1526+
self.assertFalse("(no-validate)" in " ".join(self.sg._user_agents))
1527+
self.assertTrue("(validate)" in " ".join(self.sg._user_agents))
1528+
1529+
if original_env_val is not None:
1530+
os.environ["SHOTGUN_FORCE_CERTIFICATE_VALIDATION"] = original_env_val
1531+
14731532
@patch.object(urllib2.OpenerDirector, 'open')
14741533
def test_sanitized_auth_params(self, mock_open):
14751534
# Simulate the server blowing up and giving us a 500 error

tests/test_client.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def test_detect_client_caps(self):
4848
self.assertTrue(str(client_caps).startswith("ClientCapabilities"))
4949
self.assertTrue(client_caps.py_version.startswith(str(sys.version_info[0])))
5050
self.assertTrue(client_caps.py_version.endswith(str(sys.version_info[1])))
51+
self.assertTrue(client_caps.ssl_version is not None)
52+
# todo test for version string (eg. "1.2.3ng") or "unknown"
5153

5254
def test_detect_server_caps(self):
5355
'''test_detect_server_caps tests that ServerCapabilities object is made
@@ -158,23 +160,30 @@ def test_user_agent(self):
158160
# test default user agent
159161
self.sg.info()
160162
client_caps = self.sg.client_caps
163+
config = self.sg.config
161164
args, _ = self.sg._http_request.call_args
162165
(_, _, _, headers) = args
163-
expected = "shotgun-json (%s); Python %s (%s)" % (api.__version__,
164-
client_caps.py_version,
165-
client_caps.platform.capitalize()
166-
)
166+
ssl_validate_lut = {True: "no-validate", False: "validate"}
167+
expected = "shotgun-json (%s); Python %s (%s); ssl %s (%s)" % (
168+
api.__version__,
169+
client_caps.py_version,
170+
client_caps.platform.capitalize(),
171+
client_caps.ssl_version,
172+
ssl_validate_lut[config.no_ssl_validation]
173+
)
167174
self.assertEqual(expected, headers.get("user-agent"))
168175

169176
# test adding to user agent
170177
self.sg.add_user_agent("test-agent")
171178
self.sg.info()
172179
args, _ = self.sg._http_request.call_args
173180
(_, _, _, headers) = args
174-
expected = "shotgun-json (%s); Python %s (%s); test-agent" % (
181+
expected = "shotgun-json (%s); Python %s (%s); ssl %s (%s); test-agent" % (
175182
api.__version__,
176183
client_caps.py_version,
177-
client_caps.platform.capitalize()
184+
client_caps.platform.capitalize(),
185+
client_caps.ssl_version,
186+
ssl_validate_lut[config.no_ssl_validation]
178187
)
179188
self.assertEqual(expected, headers.get("user-agent"))
180189

@@ -183,10 +192,13 @@ def test_user_agent(self):
183192
self.sg.info()
184193
args, _ = self.sg._http_request.call_args
185194
(_, _, _, headers) = args
186-
expected = "shotgun-json (%s); Python %s (%s)" % (api.__version__,
187-
client_caps.py_version,
188-
client_caps.platform.capitalize(),
189-
)
195+
expected = "shotgun-json (%s); Python %s (%s); ssl %s (%s)" % (
196+
api.__version__,
197+
client_caps.py_version,
198+
client_caps.platform.capitalize(),
199+
client_caps.ssl_version,
200+
ssl_validate_lut[config.no_ssl_validation]
201+
)
190202
self.assertEqual(expected, headers.get("user-agent"))
191203

192204
def test_connect_close(self):

0 commit comments

Comments
 (0)