diff --git a/README.md b/README.md index 6d411fd1..eee24c17 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,10 @@ In addition to the required settings data (idp, sp), extra settings can be defin // Provide the desired duration, for example PT518400S (6 days) "metadataCacheDuration": null, + // If enabled, URLs with single-label-domains will + // be allowed and not rejected by the settings validator (Enable it under Docker/Kubernetes/testing env, not recommended on production) + "allowSingleLabelDomains": false, + // Algorithm that the toolkit will use on signing process. Options: // 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' // 'http://www.w3.org/2000/09/xmldsig#dsa-sha1' diff --git a/demo-bottle/saml/advanced_settings.json b/demo-bottle/saml/advanced_settings.json index 022f70ef..c5b37c11 100644 --- a/demo-bottle/saml/advanced_settings.json +++ b/demo-bottle/saml/advanced_settings.json @@ -10,6 +10,7 @@ "wantNameId" : true, "wantNameIdEncrypted": false, "wantAssertionsEncrypted": false, + "allowSingleLabelDomains": false, "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256" }, diff --git a/demo-django/saml/advanced_settings.json b/demo-django/saml/advanced_settings.json index 022f70ef..c5b37c11 100644 --- a/demo-django/saml/advanced_settings.json +++ b/demo-django/saml/advanced_settings.json @@ -10,6 +10,7 @@ "wantNameId" : true, "wantNameIdEncrypted": false, "wantAssertionsEncrypted": false, + "allowSingleLabelDomains": false, "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256" }, diff --git a/demo-flask/saml/advanced_settings.json b/demo-flask/saml/advanced_settings.json index 022f70ef..c5b37c11 100644 --- a/demo-flask/saml/advanced_settings.json +++ b/demo-flask/saml/advanced_settings.json @@ -10,6 +10,7 @@ "wantNameId" : true, "wantNameIdEncrypted": false, "wantAssertionsEncrypted": false, + "allowSingleLabelDomains": false, "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256" }, diff --git a/demo_pyramid/demo_pyramid/saml/advanced_settings.json b/demo_pyramid/demo_pyramid/saml/advanced_settings.json index 1307b0ae..fef16fe9 100644 --- a/demo_pyramid/demo_pyramid/saml/advanced_settings.json +++ b/demo_pyramid/demo_pyramid/saml/advanced_settings.json @@ -10,6 +10,7 @@ "wantNameId" : true, "wantNameIdEncrypted": false, "wantAssertionsEncrypted": false, + "allowSingleLabelDomains": false, "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256" }, diff --git a/src/onelogin/saml2/settings.py b/src/onelogin/saml2/settings.py index 357058be..86c727df 100644 --- a/src/onelogin/saml2/settings.py +++ b/src/onelogin/saml2/settings.py @@ -31,14 +31,25 @@ r'\[?[A-F0-9]*:[A-F0-9:]+\]?)' # ...or ipv6 r'(?::\d+)?' # optional port r'(?:/?|[/?]\S+)$', re.IGNORECASE) +url_regex_single_label_domain = re.compile( + r'^(?:[a-z0-9\.\-]*)://' # scheme is validated separately + r'(?:(?:[A-Z0-9_](?:[A-Z0-9-_]{0,61}[A-Z0-9_])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + r'(?:[A-Z0-9_](?:[A-Z0-9-_]{0,61}[A-Z0-9_]))|' # single-label-domain + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|' # ...or ipv4 + r'\[?[A-F0-9]*:[A-F0-9:]+\]?)' # ...or ipv6 + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) url_schemes = ['http', 'https', 'ftp', 'ftps'] -def validate_url(url): +def validate_url(url, allow_single_label_domain=False): """ Auxiliary method to validate an urllib :param url: An url to be validated :type url: string + :param allow_single_label_domain: In order to allow or not single label domain + :type url: bool :returns: True if the url is valid :rtype: bool """ @@ -46,8 +57,12 @@ def validate_url(url): scheme = url.split('://')[0].lower() if scheme not in url_schemes: return False - if not bool(url_regex.search(url)): - return False + if allow_single_label_domain: + if not bool(url_regex_single_label_domain.search(url)): + return False + else: + if not bool(url_regex.search(url)): + return False return True @@ -351,17 +366,18 @@ def check_idp_settings(self, settings): if not settings.get('idp'): errors.append('idp_not_found') else: + allow_single_domain_urls = self._get_allow_single_label_domain(settings) idp = settings['idp'] if not idp.get('entityId'): errors.append('idp_entityId_not_found') if not idp.get('singleSignOnService', {}).get('url'): errors.append('idp_sso_not_found') - elif not validate_url(idp['singleSignOnService']['url']): + elif not validate_url(idp['singleSignOnService']['url'], allow_single_domain_urls): errors.append('idp_sso_url_invalid') slo_url = idp.get('singleLogoutService', {}).get('url') - if slo_url and not validate_url(slo_url): + if slo_url and not validate_url(slo_url, allow_single_domain_urls): errors.append('idp_slo_url_invalid') if 'security' in settings: @@ -408,6 +424,7 @@ def check_sp_settings(self, settings): if not settings.get('sp'): errors.append('sp_not_found') else: + allow_single_domain_urls = self._get_allow_single_label_domain(settings) # check_sp_certs uses self.__sp so I add it old_sp = self.__sp self.__sp = settings['sp'] @@ -420,7 +437,7 @@ def check_sp_settings(self, settings): if not sp.get('assertionConsumerService', {}).get('url'): errors.append('sp_acs_not_found') - elif not validate_url(sp['assertionConsumerService']['url']): + elif not validate_url(sp['assertionConsumerService']['url'], allow_single_domain_urls): errors.append('sp_acs_url_invalid') if sp.get('attributeConsumingService'): @@ -449,7 +466,7 @@ def check_sp_settings(self, settings): errors.append('sp_attributeConsumingService_serviceDescription_type_invalid') slo_url = sp.get('singleLogoutService', {}).get('url') - if slo_url and not validate_url(slo_url): + if slo_url and not validate_url(slo_url, allow_single_domain_urls): errors.append('sp_sls_url_invalid') if 'signMetadata' in security and isinstance(security['signMetadata'], dict): @@ -840,3 +857,7 @@ def is_debug_active(self): :rtype: boolean """ return self.__debug + + def _get_allow_single_label_domain(self, settings): + security = settings.get('security', {}) + return 'allowSingleLabelDomains' in security.keys() and security['allowSingleLabelDomains'] diff --git a/tests/src/OneLogin/saml2_tests/response_test.py b/tests/src/OneLogin/saml2_tests/response_test.py index e57fe35c..078700cc 100644 --- a/tests/src/OneLogin/saml2_tests/response_test.py +++ b/tests/src/OneLogin/saml2_tests/response_test.py @@ -714,7 +714,6 @@ def testDoesNotAllowSignatureWrappingAttack(self): self.assertFalse(response.is_valid(self.get_request_data())) self.assertEqual('test@onelogin.com', response.get_nameid()) - def testDoesNotAllowSignatureWrappingAttack2(self): # Signature Wraping attack 2 settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) @@ -724,7 +723,6 @@ def testDoesNotAllowSignatureWrappingAttack2(self): self.assertFalse(response.is_valid(self.get_request_data())) self.assertEquals("SAML Response must contain 1 assertion", response.get_error()) - def testNodeTextAttack(self): """ Tests the get_nameid and get_attributes methods of the OneLogin_Saml2_Response diff --git a/tests/src/OneLogin/saml2_tests/settings_test.py b/tests/src/OneLogin/saml2_tests/settings_test.py index 9027c425..3d11effa 100644 --- a/tests/src/OneLogin/saml2_tests/settings_test.py +++ b/tests/src/OneLogin/saml2_tests/settings_test.py @@ -64,6 +64,13 @@ def testLoadSettingsFromDict(self): with self.assertRaisesRegexp(Exception, 'Invalid dict settings: idp_sso_url_invalid'): OneLogin_Saml2_Settings(settings_info) + settings_info['idp']['singleSignOnService']['url'] = 'http://single-label-domain' + settings_info['security'] = {} + settings_info['security']['allowSingleLabelDomains'] = True + settings = OneLogin_Saml2_Settings(settings_info) + self.assertEqual(len(settings.get_errors()), 0) + + del settings_info['security'] del settings_info['sp'] del settings_info['idp'] with self.assertRaisesRegexp(Exception, 'Invalid dict settings: idp_not_found,sp_not_found'):