Skip to content

Commit b8f8b4d

Browse files
committed
Added server cert verification
1 parent ceb475f commit b8f8b4d

File tree

3 files changed

+169
-2
lines changed

3 files changed

+169
-2
lines changed

paypal/https_connection.py

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright 2007,2011 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
# This file is derived from
17+
# http://googleappengine.googlecode.com/svn-history/r136/trunk/python/google/appengine/tools/https_wrapper.py
18+
19+
20+
"""Extensions to allow HTTPS requests with SSL certificate validation."""
21+
22+
import httplib
23+
import re
24+
import socket
25+
import ssl
26+
import urllib2
27+
28+
class InvalidCertificateException(httplib.HTTPException):
29+
"""Raised when a certificate is provided with an invalid hostname."""
30+
31+
def __init__(self, host, cert, reason):
32+
"""Constructor.
33+
34+
Args:
35+
host: The hostname the connection was made to.
36+
cert: The SSL certificate (as a dictionary) the host returned.
37+
"""
38+
httplib.HTTPException.__init__(self)
39+
self.host = host
40+
self.cert = cert
41+
self.reason = reason
42+
43+
def __str__(self):
44+
return ('Host %s returned an invalid certificate (%s): %s' %
45+
(self.host, self.reason, self.cert))
46+
47+
def GetValidHostsForCert(cert):
48+
"""Returns a list of valid host globs for an SSL certificate.
49+
50+
Args:
51+
cert: A dictionary representing an SSL certificate.
52+
Returns:
53+
list: A list of valid host globs.
54+
"""
55+
if 'subjectAltName' in cert:
56+
return [x[1] for x in cert['subjectAltName'] if x[0].lower() == 'dns']
57+
else:
58+
return [x[0][1] for x in cert['subject']
59+
if x[0][0].lower() == 'commonname']
60+
61+
def ValidateCertificateHostname(cert, hostname):
62+
"""Validates that a given hostname is valid for an SSL certificate.
63+
64+
Args:
65+
cert: A dictionary representing an SSL certificate.
66+
hostname: The hostname to test.
67+
Returns:
68+
bool: Whether or not the hostname is valid for this certificate.
69+
"""
70+
hosts = GetValidHostsForCert(cert)
71+
for host in hosts:
72+
host_re = host.replace('.', '\.').replace('*', '[^.]*')
73+
if re.search('^%s$' % (host_re,), hostname, re.I):
74+
return True
75+
return False
76+
77+
78+
class CertValidatingHTTPSConnection(httplib.HTTPConnection):
79+
"""An HTTPConnection that connects over SSL and validates certificates."""
80+
81+
default_port = httplib.HTTPS_PORT
82+
83+
def __init__(self, host, port=None, key_file=None, cert_file=None,
84+
ca_certs=None, strict=None, **kwargs):
85+
"""Constructor.
86+
87+
Args:
88+
host: The hostname. Can be in 'host:port' form.
89+
port: The port. Defaults to 443.
90+
key_file: A file containing the client's private key
91+
cert_file: A file containing the client's certificates
92+
ca_certs: A file contianing a set of concatenated certificate authority
93+
certs for validating the server against.
94+
strict: When true, causes BadStatusLine to be raised if the status line
95+
can't be parsed as a valid HTTP/1.0 or 1.1 status line.
96+
"""
97+
httplib.HTTPConnection.__init__(self, host, port, strict, **kwargs)
98+
self.key_file = key_file
99+
self.cert_file = cert_file
100+
self.ca_certs = ca_certs
101+
102+
def connect(self):
103+
"Connect to a host on a given (SSL) port."
104+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
105+
sock.connect((self.host, self.port))
106+
self.sock = ssl.wrap_socket(sock, keyfile=self.key_file,
107+
certfile=self.cert_file,
108+
cert_reqs=ssl.CERT_REQUIRED,
109+
ca_certs=self.ca_certs)
110+
cert = self.sock.getpeercert()
111+
hostname = self.host.split(':', 0)[0]
112+
if not ValidateCertificateHostname(cert, hostname):
113+
raise InvalidCertificateException(hostname,
114+
cert,
115+
'remote hostname "%s" does not match '\
116+
'certificate' % hostname)
117+
118+
119+
class CertValidatingHTTPSHandler(urllib2.HTTPSHandler):
120+
"""An HTTPHandler that validates SSL certificates."""
121+
122+
def __init__(self, **kwargs):
123+
"""Constructor. Any keyword args are passed to the httplib handler."""
124+
urllib2.HTTPSHandler.__init__(self)
125+
self._connection_args = kwargs
126+
127+
def https_open(self, req):
128+
def http_class_wrapper(host, **kwargs):
129+
full_kwargs = dict(self._connection_args)
130+
full_kwargs.update(kwargs)
131+
return CertValidatingHTTPSConnection(host, **full_kwargs)
132+
try:
133+
return self.do_open(http_class_wrapper, req)
134+
except urllib2.URLError, e:
135+
if type(e.reason) == ssl.SSLError and e.reason.args[0] == 1:
136+
raise InvalidCertificateException(req.host, '',
137+
e.reason.args[1])
138+
raise
139+
140+
https_request = urllib2.HTTPSHandler.do_request_
141+

paypal/interface.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from paypal.settings import PayPalConfig
1616
from paypal.response import PayPalResponse
1717
from paypal.exceptions import PayPalError, PayPalAPIResponseError
18+
from paypal.https_connection import CertValidatingHTTPSHandler
1819

1920
logger = logging.getLogger('paypal.interface')
2021

@@ -40,7 +41,7 @@ def __init__(self , config=None, **kwargs):
4041
else:
4142
# Take the kwargs and stuff them in a new PayPalConfig object.
4243
self.config = PayPalConfig(**kwargs)
43-
44+
4445
def _encode_utf8(self, **kwargs):
4546
"""
4647
UTF8 encodes all of the NVP values.
@@ -99,7 +100,20 @@ def _call(self, method, **kwargs):
99100
url = self._encode_utf8(**url_values)
100101
data = urllib.urlencode(url).encode('utf-8')
101102
req = urllib2.Request(self.config.API_ENDPOINT, data)
102-
response = PayPalResponse(urllib2.urlopen(req).read().decode('utf-8'),
103+
104+
# If certificate provided build an opener that will validate PayPal server cert
105+
if self.config.API_CA_CERTS:
106+
handler = CertValidatingHTTPSHandler(ca_certs=self.config.API_CA_CERTS)
107+
opener = urllib2.build_opener(handler)
108+
if logger.isEnabledFor(logging.DEBUG):
109+
logger.debug('Validating PayPal server with certificate:\n%s\n' % self.config.API_CA_CERTS)
110+
else:
111+
opener = urllib2.build_opener()
112+
if logger.isEnabledFor(logging.DEBUG):
113+
logger.debug('Skipping PayPal server certificate validation')
114+
115+
# Call paypal API
116+
response = PayPalResponse(opener.open(req).read().decode('utf-8'),
103117
self.config)
104118

105119
logger.debug('PayPal NVP API Endpoint: %s'% self.config.API_ENDPOINT)

paypal/settings.py

+12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
is instantiated by the PayPalInterface object.
66
"""
77
import logging
8+
import os
89
from pprint import pformat
910

1011
from paypal.exceptions import PayPalConfigError, PayPalError
@@ -54,6 +55,10 @@ class PayPalConfig(object):
5455
# API Endpoints are just API server addresses.
5556
API_ENDPOINT = None
5657
PAYPAL_URL_BASE = None
58+
59+
# API Endpoint CA certificate chain
60+
# (filename with path e.g. '/etc/ssl/certs/Verisign_Class_3_Public_Primary_Certification_Authority.pem')
61+
API_CA_CERTS = None
5762

5863
# UNIPAY credentials
5964
UNIPAY_SUBJECT = None
@@ -92,6 +97,13 @@ def __init__(self, **kwargs):
9297
self.API_ENDPOINT= self._API_ENDPOINTS[self.API_AUTHENTICATION_MODE][self.API_ENVIRONMENT]
9398
self.PAYPAL_URL_BASE= self._PAYPAL_URL_BASE[self.API_ENVIRONMENT]
9499

100+
# Set the CA_CERTS location
101+
if 'API_CA_CERTS' not in kwargs:
102+
kwargs['API_CA_CERTS']= self.API_CA_CERTS
103+
if kwargs['API_CA_CERTS'] and not os.path.exists(kwargs['API_CA_CERTS']):
104+
raise PayPalConfigError('Invalid API_CA_CERTS')
105+
self.API_CA_CERTS = kwargs['API_CA_CERTS']
106+
95107
# set the 3TOKEN required fields
96108
if self.API_AUTHENTICATION_MODE == '3TOKEN':
97109
for arg in ('API_USERNAME','API_PASSWORD','API_SIGNATURE'):

0 commit comments

Comments
 (0)