Skip to content

Commit 5af6d58

Browse files
committed
Security improved, added more checks at the SAMLResponse. Request Data used as parameter of the Response constructor
1 parent 7198d9e commit 5af6d58

3 files changed

Lines changed: 242 additions & 25 deletions

File tree

example.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ def _bad_request(self):
4343
"""Serve Bad Request (400)."""
4444
self._serve_msg(400, 'Bad Request')
4545

46+
def prepare_request(self):
47+
request_data = {}
48+
request_data['server_name'] = self.server.server_name
49+
request_data['server_port'] = str(self.server.server_port)
50+
request_data['path_info'] = self.path
51+
request_data['request_uri'] = self.path
52+
request_data['script_name'] = ''
53+
if self.protocol_version == 'HTTP/1.0':
54+
request_data['https'] = 'off'
55+
else:
56+
request_data['https'] = 'on'
57+
return request_data
58+
4659
def log_message(self, format, *args):
4760
log.info(format % args)
4861

@@ -72,11 +85,12 @@ def do_POST(self):
7285
if not self.path == self.saml_post_path:
7386
self._bad_request()
7487
return
75-
88+
request_data = self.prepare_request()
7689
length = int(self.headers['Content-Length'])
7790
data = self.rfile.read(length)
7891
query = urlparse.parse_qs(data)
7992
res = Response(
93+
request_data,
8094
query['SAMLResponse'].pop(),
8195
self.settings['idp_cert_fingerprint'],
8296
issuer=self.settings['issuer']

onelogin/saml/Response.py

Lines changed: 132 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from datetime import datetime, timedelta
55

66
from onelogin.saml import SignatureVerifier
7+
from onelogin.saml.Utils import get_self_url_no_query
78

89

910
namespaces = dict(
1011
samlp='urn:oasis:names:tc:SAML:2.0:protocol',
1112
saml='urn:oasis:names:tc:SAML:2.0:assertion',
13+
ds='http://www.w3.org/2000/09/xmldsig#',
1214
)
1315

1416

@@ -38,8 +40,36 @@ def __init__(self, msg):
3840
def __str__(self):
3941
return '%s: %s' % (self.__doc__, self._msg)
4042

43+
44+
class ResponseFormatError(Exception):
45+
"""There was a problem validating the format"""
46+
def __init__(self, msg):
47+
self._msg = msg
48+
49+
def __str__(self):
50+
return '%s: %s' % (self.__doc__, self._msg)
51+
52+
53+
class ResponseDestinationError(Exception):
54+
"""There was a problem validating a destination"""
55+
def __init__(self, msg):
56+
self._msg = msg
57+
58+
def __str__(self):
59+
return '%s: %s' % (self.__doc__, self._msg)
60+
61+
62+
class ResponseSubjectConfirmationError(Exception):
63+
"""There was a problem validating the response, no valid SubjectConfirmation found"""
64+
def __init__(self, msg):
65+
self._msg = msg
66+
67+
def __str__(self):
68+
return '%s: %s' % (self.__doc__, self._msg)
69+
70+
4171
class Response(object):
42-
def __init__(self, response, signature, _base64=None, _etree=None, issuer=None):
72+
def __init__(self, request_data, response, signature, _base64=None, _etree=None, issuer=None):
4373
"""
4474
Extract information from an samlp:Response
4575
Arguments:
@@ -51,6 +81,7 @@ def __init__(self, response, signature, _base64=None, _etree=None, issuer=None):
5181
if _etree is None:
5282
_etree = etree
5383

84+
self._request_data = request_data
5485
decoded_response = _base64.b64decode(response)
5586
self._document = _etree.fromstring(decoded_response)
5687
self._signature = signature
@@ -93,11 +124,58 @@ def get_assertion_attribute_value(self, attribute_name):
93124
result = self._document.xpath('/samlp:Response/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name="%s"]/saml:AttributeValue' % attribute_name, namespaces=namespaces)
94125
return [n.text.strip() for n in result]
95126

127+
def get_audiences(self):
128+
"""
129+
Gets the audiences
130+
131+
:returns: The valid audiences for the SAML Response
132+
:rtype: list
133+
"""
134+
audiences = []
135+
136+
audience_nodes = self._document.xpath(
137+
'/samlp:Response/saml:Assertion/saml:Conditions/saml:AudienceRestriction/saml:Audience',
138+
namespaces=namespaces,
139+
)
140+
for audience_node in audience_nodes:
141+
audiences.append(audience_node.text)
142+
return audiences
143+
144+
def validate_num_assertions(self):
145+
"""
146+
Verifies that the document only contains a single Assertion (encrypted or not)
147+
148+
:returns: True if only 1 assertion encrypted or not
149+
:rtype: bool
150+
"""
151+
#Not encrypted assertion supported yet
152+
#encrypted_assertion_nodes = self._document.xpath('//saml:EncryptedAssertion')
153+
assertion_nodes = self._document.xpath(
154+
'//saml:Assertion',
155+
namespaces=namespaces,
156+
)
157+
return len(assertion_nodes) == 1
158+
96159
def is_valid(self, _clock=None, _verifier=None):
97160
"""
98161
Verify that the samlp:Response is valid.
99162
Return True if valid, otherwise False.
100163
"""
164+
165+
# Checks SAML version
166+
if self._document.get('Version', None) != '2.0':
167+
raise ResponseFormatError('Unsupported SAML version')
168+
169+
# Checks that ID exists
170+
if self._document.get('ID', None) is None:
171+
raise ResponseFormatError('Missing ID attribute on SAML Response')
172+
173+
# Checks that the response only has one assertion
174+
if not self.validate_num_assertions():
175+
raise ResponseFormatError('Only 1 Assertion in the SAMLResponse is supported')
176+
177+
178+
101179
if _clock is None:
102180
_clock = datetime.utcnow
103181
if _verifier is None:
@@ -110,44 +188,74 @@ def is_valid(self, _clock=None, _verifier=None):
110188

111189
now = _clock()
112190

113-
foundCondition = False
114-
fountConditionAndAudience = False
115-
116191
for condition in conditions:
117192

118193
not_before = condition.attrib.get('NotBefore', None)
119194
not_on_or_after = condition.attrib.get('NotOnOrAfter', None)
120195

121196
if not_before is None:
122-
#notbefore condition is not mandatory. If it is not specified, use yesterday as not_before condition
123197
not_before = (now - timedelta(0, 5, 0)).strftime('%Y-%m-%dT%H:%M:%SZ')
124198
if not_on_or_after is None:
125-
continue
199+
not_on_or_after = (now + timedelta(0, 5, 0)).strftime('%Y-%m-%dT%H:%M:%SZ')
126200

127201
not_before = self._parse_datetime(not_before)
128202
not_on_or_after = self._parse_datetime(not_on_or_after)
129203

130204
if now < not_before:
131-
continue
205+
raise ResponseConditionError('Timmig issue')
132206
if now >= not_on_or_after:
207+
raise ResponseConditionError('Timmig issue')
208+
209+
current_url = get_self_url_no_query(self._request_data)
210+
211+
# Checks destination
212+
destination = self._document.get('Destination', None)
213+
if destination and destination not in current_url:
214+
raise ResponseDestinationError('The response was received at %s instead of %s' % (current_url, destination))
215+
216+
# Checks audience
217+
valid_audiences = self.get_audiences()
218+
if valid_audiences and self._issuer not in valid_audiences:
219+
raise ResponseConditionError('%s is not a valid audience for this Response' % self._issuer)
220+
221+
# Checks the SubjectConfirmation, at least one SubjectConfirmation must be valid
222+
any_subject_confirmation = False
223+
subject_confirmation_nodes = self._document.xpath(
224+
'//saml:Subject/saml:SubjectConfirmation',
225+
namespaces=namespaces
226+
)
227+
228+
in_response_to = self._document.get('InResponseTo', None)
229+
for scn in subject_confirmation_nodes:
230+
method = scn.get('Method', None)
231+
if method and method != 'urn:oasis:names:tc:SAML:2.0:cm:bearer':
133232
continue
134-
foundCondition = True
135-
136-
if self._issuer:
137-
audiences = condition.xpath(
138-
'/samlp:Response/saml:Assertion/saml:Conditions/saml:AudienceRestriction/saml:Audience',
139-
namespaces=namespaces,
140-
)
141-
audienceValues = []
142-
for audience in audiences:
143-
audienceValues.append(audience.text)
144-
if self._issuer in audienceValues:
145-
fountConditionAndAudience = True
146-
147-
if not foundCondition:
148-
raise ResponseConditionError('Timmig issue')
149-
if foundCondition and not fountConditionAndAudience:
150-
raise ResponseConditionError('Not valid Audience')
233+
scData = scn.find('saml:SubjectConfirmationData', namespaces=namespaces)
234+
if scData is None:
235+
continue
236+
else:
237+
irt = scData.get('InResponseTo', None)
238+
if irt != in_response_to:
239+
continue
240+
recipient = scData.get('Recipient', None)
241+
if recipient not in current_url:
242+
continue
243+
nooa = scData.get('NotOnOrAfter', None)
244+
if nooa:
245+
parsed_nooa = self._parse_datetime(nooa)
246+
if parsed_nooa <= now:
247+
continue
248+
nb = scData.get('NotBefore', None)
249+
if nb:
250+
parsed_nb = self._parse_datetime(nb)
251+
if parsed_nb > now:
252+
continue
253+
any_subject_confirmation = True
254+
break
255+
256+
if not any_subject_confirmation:
257+
raise ResponseSubjectConfirmationError('A valid SubjectConfirmation was not found on this Response')
258+
151259

152260
return _verifier(
153261
self._document,

onelogin/saml/Utils.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,98 @@ def format_cert(cert, heads=True):
7777
x509_cert = '-----BEGIN CERTIFICATE-----\n' + '\n'.join(wrap(x509_cert, 64)) + '\n-----END CERTIFICATE-----\n'
7878

7979
return x509_cert
80+
81+
82+
def get_self_url_no_query(request_data):
83+
"""
84+
Returns the URL of the current host + current view.
85+
86+
:param request_data: The request as a dict
87+
:type: dict
88+
89+
:return: The url of current host + current view
90+
:rtype: string
91+
"""
92+
self_url_host = get_self_url_host(request_data)
93+
script_name = request_data['script_name']
94+
if script_name and script_name[0] != '/':
95+
script_name = '/' + script_name
96+
self_url_host += script_name
97+
if 'path_info' in request_data:
98+
self_url_host += request_data['path_info']
99+
100+
return self_url_host
101+
102+
103+
def get_self_url_host(request_data):
104+
"""
105+
Returns the protocol + the current host + the port (if different than
106+
common ports).
107+
108+
:param request_data: The request as a dict
109+
:type: dict
110+
111+
:return: Url
112+
:rtype: string
113+
"""
114+
current_host = get_self_host(request_data)
115+
port = ''
116+
if is_https(request_data):
117+
protocol = 'https'
118+
else:
119+
protocol = 'http'
120+
121+
if 'server_port' in request_data:
122+
port_number = request_data['server_port']
123+
port = ':' + port_number
124+
125+
if protocol == 'http' and port_number == '80':
126+
port = ''
127+
elif protocol == 'https' and port_number == '443':
128+
port = ''
129+
130+
return '%s://%s%s' % (protocol, current_host, port)
131+
132+
133+
def get_self_host(request_data):
134+
"""
135+
Returns the current host.
136+
137+
:param request_data: The request as a dict
138+
:type: dict
139+
140+
:return: The current host
141+
:rtype: string
142+
"""
143+
if 'http_host' in request_data:
144+
current_host = request_data['http_host']
145+
elif 'server_name' in request_data:
146+
current_host = request_data['server_name']
147+
else:
148+
raise Exception('No hostname defined')
149+
150+
if ':' in current_host:
151+
current_host_data = current_host.split(':')
152+
possible_port = current_host_data[-1]
153+
try:
154+
possible_port = float(possible_port)
155+
current_host = current_host_data[0]
156+
except ValueError:
157+
current_host = ':'.join(current_host_data)
158+
159+
return current_host
160+
161+
162+
def is_https(request_data):
163+
"""
164+
Checks if https or http.
165+
166+
:param request_data: The request as a dict
167+
:type: dict
168+
169+
:return: False if https is not active
170+
:rtype: boolean
171+
"""
172+
is_https = 'https' in request_data and request_data['https'] != 'off'
173+
is_https = is_https or ('server_port' in request_data and request_data['server_port'] == '443')
174+
return is_https

0 commit comments

Comments
 (0)