Skip to content

Commit b2345fd

Browse files
committed
Merge branch 'issue_226' of https://github.com/tbloomer4/python3-saml into tbloomer4-issue_226
2 parents 52f0daa + a8d03a5 commit b2345fd

File tree

8 files changed

+221
-7
lines changed

8 files changed

+221
-7
lines changed

src/onelogin/saml2/logout_request.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ def is_valid(self, request_data, raise_exceptions=False):
314314
if root.get('Destination', None):
315315
destination = root.get('Destination')
316316
if destination != '':
317-
if current_url not in destination:
317+
if OneLogin_Saml2_Utils.normalize_url(current_url) not in OneLogin_Saml2_Utils.normalize_url(destination):
318318
raise OneLogin_Saml2_ValidationError(
319319
'The LogoutRequest was received at '
320320
'%(currentURL)s instead of %(destination)s' %

src/onelogin/saml2/logout_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
118118

119119
# Check destination
120120
destination = self.document.get('Destination', None)
121-
if destination and current_url not in destination:
121+
if destination and OneLogin_Saml2_Utils.normalize_url(url=current_url) not in OneLogin_Saml2_Utils.normalize_url(url=destination):
122122
raise OneLogin_Saml2_ValidationError(
123123
'The LogoutResponse was received at %s instead of %s' % (current_url, destination),
124124
OneLogin_Saml2_ValidationError.WRONG_DESTINATION

src/onelogin/saml2/response.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
from copy import deepcopy
13+
from urllib.parse import urlsplit, urlunsplit
1314
from onelogin.saml2.constants import OneLogin_Saml2_Constants
1415
from onelogin.saml2.utils import OneLogin_Saml2_Utils, OneLogin_Saml2_Error, OneLogin_Saml2_ValidationError, return_false_on_exception
1516
from onelogin.saml2.xml_utils import OneLogin_Saml2_XML
@@ -191,7 +192,7 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
191192
# Checks destination
192193
destination = self.document.get('Destination', None)
193194
if destination:
194-
if not destination.startswith(current_url):
195+
if not OneLogin_Saml2_Utils.normalize_url(url=destination).startswith(OneLogin_Saml2_Utils.normalize_url(url=current_url)):
195196
# TODO: Review if following lines are required, since we can control the
196197
# request_data
197198
# current_url_routed = OneLogin_Saml2_Utils.get_self_routed_url_no_query(request_data)
@@ -901,7 +902,7 @@ def __decrypt_assertion(self, xml):
901902
decrypted = OneLogin_Saml2_Utils.decrypt_element(encrypted_data, key, debug=debug, inplace=True)
902903
xml.replace(encrypted_assertion_nodes[0], decrypted)
903904
return xml
904-
905+
905906
def get_error(self):
906907
"""
907908
After executing a validation process, if it fails this method returns the cause

src/onelogin/saml2/utils.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from functools import wraps
2121
from uuid import uuid4
2222
from xml.dom.minidom import Element
23-
23+
from urllib.parse import urlsplit, urlunsplit
2424
import zlib
2525
import xmlsec
2626

@@ -1062,3 +1062,24 @@ def validate_binary_sign(signed_query, signature, cert=None, algorithm=OneLogin_
10621062
if debug:
10631063
print(e)
10641064
return False
1065+
1066+
@staticmethod
1067+
def normalize_url(url):
1068+
"""
1069+
Returns normalized URL for comparison.
1070+
This method converts the netloc to lowercase, as it should be case-insensitive (per RFC 4343, RFC 7617)
1071+
If standardization fails, the original URL is returned
1072+
Python documentation indicates that URL split also normalizes query strings if empty query fields are present
1073+
1074+
:param url: URL
1075+
:type url: String
1076+
1077+
:returns: A normalized URL, or the given URL string if parsing fails
1078+
:rtype: String
1079+
"""
1080+
try:
1081+
scheme, netloc, *rest = urlsplit(url)
1082+
normalized_url = urlunsplit((scheme.lower(), netloc.lower(), *rest))
1083+
return normalized_url
1084+
except Exception:
1085+
return url

tests/src/OneLogin/saml2_tests/logout_request_test.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,70 @@ def testIsValid(self):
413413
logout_request5 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
414414
self.assertTrue(logout_request5.is_valid(request_data))
415415

416+
def testIsValidWithCapitalization(self):
417+
"""
418+
Tests the is_valid method of the OneLogin_Saml2_LogoutRequest
419+
"""
420+
request_data = {
421+
'http_host': 'exaMPLe.com',
422+
'script_name': 'index.html'
423+
}
424+
request = self.file_contents(join(self.data_path, 'logout_requests', 'logout_request.xml'))
425+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
426+
427+
logout_request = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
428+
self.assertTrue(logout_request.is_valid(request_data))
429+
430+
settings.set_strict(True)
431+
logout_request2 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
432+
self.assertFalse(logout_request2.is_valid(request_data))
433+
434+
settings.set_strict(False)
435+
dom = parseString(request)
436+
logout_request3 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(dom.toxml()))
437+
self.assertTrue(logout_request3.is_valid(request_data))
438+
439+
settings.set_strict(True)
440+
logout_request4 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(dom.toxml()))
441+
self.assertFalse(logout_request4.is_valid(request_data))
442+
443+
current_url = OneLogin_Saml2_Utils.get_self_url_no_query(request_data)
444+
request = request.replace('http://stuff.com/endpoints/endpoints/sls.php', current_url.lower())
445+
logout_request5 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
446+
self.assertTrue(logout_request5.is_valid(request_data))
447+
448+
def testIsInValidWithCapitalization(self):
449+
"""
450+
Tests the is_valid method of the OneLogin_Saml2_LogoutRequest
451+
"""
452+
request_data = {
453+
'http_host': 'example.com',
454+
'script_name': 'INdex.html'
455+
}
456+
request = self.file_contents(join(self.data_path, 'logout_requests', 'logout_request.xml'))
457+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
458+
459+
logout_request = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
460+
self.assertTrue(logout_request.is_valid(request_data))
461+
462+
settings.set_strict(True)
463+
logout_request2 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
464+
self.assertFalse(logout_request2.is_valid(request_data))
465+
466+
settings.set_strict(False)
467+
dom = parseString(request)
468+
logout_request3 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(dom.toxml()))
469+
self.assertTrue(logout_request3.is_valid(request_data))
470+
471+
settings.set_strict(True)
472+
logout_request4 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(dom.toxml()))
473+
self.assertFalse(logout_request4.is_valid(request_data))
474+
475+
current_url = OneLogin_Saml2_Utils.get_self_url_no_query(request_data)
476+
request = request.replace('http://stuff.com/endpoints/endpoints/sls.php', current_url.lower())
477+
logout_request5 = OneLogin_Saml2_Logout_Request(settings, OneLogin_Saml2_Utils.b64encode(request))
478+
self.assertFalse(logout_request5.is_valid(request_data))
479+
416480
def testIsValidWithXMLEncoding(self):
417481
"""
418482
Tests the is_valid method of the OneLogin_Saml2_LogoutRequest

tests/src/OneLogin/saml2_tests/logout_response_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,64 @@ def testIsValid(self):
276276
response_3 = OneLogin_Saml2_Logout_Response(settings, message_3)
277277
self.assertTrue(response_3.is_valid(request_data))
278278

279+
def testIsValidWithCapitalization(self):
280+
"""
281+
Tests the is_valid method of the OneLogin_Saml2_LogoutResponse
282+
"""
283+
request_data = {
284+
'http_host': 'exaMPLe.com',
285+
'script_name': 'index.html',
286+
'get_data': {}
287+
}
288+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
289+
message = self.file_contents(join(self.data_path, 'logout_responses', 'logout_response_deflated.xml.base64'))
290+
291+
response = OneLogin_Saml2_Logout_Response(settings, message)
292+
self.assertTrue(response.is_valid(request_data))
293+
294+
settings.set_strict(True)
295+
response_2 = OneLogin_Saml2_Logout_Response(settings, message)
296+
with self.assertRaisesRegex(Exception, 'The LogoutResponse was received at'):
297+
response_2.is_valid(request_data, raise_exceptions=True)
298+
299+
plain_message = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(message))
300+
301+
current_url = OneLogin_Saml2_Utils.get_self_url_no_query(request_data).lower()
302+
plain_message = plain_message.replace('http://stuff.com/endpoints/endpoints/sls.php', current_url)
303+
message_3 = OneLogin_Saml2_Utils.deflate_and_base64_encode(plain_message)
304+
305+
response_3 = OneLogin_Saml2_Logout_Response(settings, message_3)
306+
self.assertTrue(response_3.is_valid(request_data))
307+
308+
def testIsInValidWithCapitalization(self):
309+
"""
310+
Tests the is_valid method of the OneLogin_Saml2_LogoutResponse
311+
"""
312+
request_data = {
313+
'http_host': 'example.com',
314+
'script_name': 'INdex.html',
315+
'get_data': {}
316+
}
317+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
318+
message = self.file_contents(join(self.data_path, 'logout_responses', 'logout_response_deflated.xml.base64'))
319+
320+
response = OneLogin_Saml2_Logout_Response(settings, message)
321+
self.assertTrue(response.is_valid(request_data))
322+
323+
settings.set_strict(True)
324+
response_2 = OneLogin_Saml2_Logout_Response(settings, message)
325+
with self.assertRaisesRegex(Exception, 'The LogoutResponse was received at'):
326+
response_2.is_valid(request_data, raise_exceptions=True)
327+
328+
plain_message = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(message))
329+
current_url = OneLogin_Saml2_Utils.get_self_url_no_query(request_data).lower()
330+
plain_message = plain_message.replace('http://stuff.com/endpoints/endpoints/sls.php', current_url)
331+
message_3 = OneLogin_Saml2_Utils.deflate_and_base64_encode(plain_message)
332+
333+
response_3 = OneLogin_Saml2_Logout_Response(settings, message_3)
334+
self.assertFalse(response_3.is_valid(request_data))
335+
336+
279337
def testIsValidWithXMLEncoding(self):
280338
"""
281339
Tests the is_valid method of the OneLogin_Saml2_LogoutResponse

tests/src/OneLogin/saml2_tests/response_test.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from onelogin.saml2.settings import OneLogin_Saml2_Settings
1919
from onelogin.saml2.utils import OneLogin_Saml2_Utils
2020

21-
2221
class OneLogin_Saml2_Response_Test(unittest.TestCase):
2322
data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data')
2423
settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings')
@@ -50,6 +49,24 @@ def get_request_data(self):
5049
'script_name': 'index.html'
5150
}
5251

52+
def get_request_data_domain_capitalized(self):
53+
return {
54+
'http_host': 'StuFF.Com',
55+
'script_name': 'endpoints/endpoints/acs.php'
56+
}
57+
58+
def get_request_data_path_capitalized(self):
59+
return {
60+
'http_host': 'stuff.com',
61+
'script_name': 'Endpoints/endPoints/acs.php'
62+
}
63+
64+
def get_request_data_both_capitalized(self):
65+
return {
66+
'http_host': 'StuFF.Com',
67+
'script_name': 'Endpoints/endPoints/aCs.php'
68+
}
69+
5370
def testConstruct(self):
5471
"""
5572
Tests the OneLogin_Saml2_Response Constructor.
@@ -1001,7 +1018,7 @@ def testIsInValidDuplicatedAttrs(self):
10011018
response = OneLogin_Saml2_Response(settings, xml)
10021019
with self.assertRaisesRegex(Exception, 'Found an Attribute element with duplicated Name'):
10031020
response.get_attributes()
1004-
1021+
10051022
def testIsInValidDestination(self):
10061023
"""
10071024
Tests the is_valid method of the OneLogin_Saml2_Response class
@@ -1038,6 +1055,47 @@ def testIsInValidDestination(self):
10381055
self.assertFalse(response_5.is_valid(self.get_request_data()))
10391056
self.assertIn('A valid SubjectConfirmation was not found on this Response', response_5.get_error())
10401057

1058+
settings.set_strict(True)
1059+
response_2 = OneLogin_Saml2_Response(settings, message)
1060+
self.assertFalse(response_2.is_valid(self.get_request_data()))
1061+
self.assertIn('The response was received at', response_2.get_error())
1062+
1063+
def testIsInValidDestinationCapitalizationOfElements(self):
1064+
"""
1065+
Tests the is_valid method of the OneLogin_Saml2_Response class
1066+
Case Invalid Response due to differences in capitalization of path
1067+
"""
1068+
1069+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
1070+
message = self.file_contents(join(self.data_path, 'responses', 'unsigned_response.xml.base64'))
1071+
1072+
#Test path capitalized
1073+
settings.set_strict(True)
1074+
response = OneLogin_Saml2_Response(settings, message)
1075+
self.assertFalse(response.is_valid(self.get_request_data_path_capitalized()))
1076+
self.assertIn('The response was received at', response.get_error())
1077+
1078+
#Test both domain and path capitalized
1079+
response_2 = OneLogin_Saml2_Response(settings, message)
1080+
self.assertFalse(response_2.is_valid(self.get_request_data_both_capitalized()))
1081+
self.assertIn('The response was received at', response_2.get_error())
1082+
1083+
def testIsValidDestinationCapitalizationOfHost(self):
1084+
"""
1085+
Tests the is_valid method of the OneLogin_Saml2_Response class
1086+
Case Valid Response, even if host is differently capitalized (per RFC)
1087+
"""
1088+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
1089+
message = self.file_contents(join(self.data_path, 'responses', 'unsigned_response.xml.base64'))
1090+
#Test domain capitalized
1091+
settings.set_strict(True)
1092+
response = OneLogin_Saml2_Response(settings, message)
1093+
self.assertFalse(response.is_valid(self.get_request_data_domain_capitalized()))
1094+
self.assertNotIn('The response was received at', response.get_error())
1095+
1096+
#Assert we got past the destination check, which appears later
1097+
self.assertIn('A valid SubjectConfirmation was not found', response.get_error())
1098+
10411099
def testIsInValidAudience(self):
10421100
"""
10431101
Tests the is_valid method of the OneLogin_Saml2_Response class

tests/src/OneLogin/saml2_tests/utils_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,3 +917,15 @@ def testValidateSign(self):
917917
# Signature Wrapping attack
918918
wrapping_attack1 = b64decode(self.file_contents(join(self.data_path, 'responses', 'invalids', 'signature_wrapping_attack.xml.base64')))
919919
self.assertFalse(OneLogin_Saml2_Utils.validate_sign(wrapping_attack1, cert))
920+
921+
def testNormalizeUrl(self):
922+
base_url = 'https://blah.com/path'
923+
capital_scheme = 'hTTps://blah.com/path'
924+
capital_domain = 'https://blAH.Com/path'
925+
capital_path = 'https://blah.com/PAth'
926+
capital_all = 'HTTPS://BLAH.COM/PATH'
927+
928+
self.assertIn(base_url, OneLogin_Saml2_Utils.normalize_url(capital_scheme))
929+
self.assertIn(base_url, OneLogin_Saml2_Utils.normalize_url(capital_domain))
930+
self.assertNotIn(base_url, OneLogin_Saml2_Utils.normalize_url(capital_path))
931+
self.assertNotIn(base_url, OneLogin_Saml2_Utils.normalize_url(capital_all))

0 commit comments

Comments
 (0)