diff --git a/msal/authority.py b/msal/authority.py index 1b6fefa7..f902e10c 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -250,8 +250,10 @@ def has_valid_issuer(self): authority_parsed.netloc == issuer_parsed.netloc): return True - # Case 5: Check if issuer host ends with any well-known B2C host (e.g., tenant.b2clogin.com) - if any(issuer_host.endswith(h) for h in WELL_KNOWN_B2C_HOSTS): + # Case 5: Check if issuer host is a subdomain of a well-known B2C host + # e.g., tenant.b2clogin.com matches .b2clogin.com + # but fakeb2clogin.com does not + if any(issuer_host.endswith("." + h) for h in WELL_KNOWN_B2C_HOSTS): return True return False diff --git a/tests/test_authority.py b/tests/test_authority.py index d19a8b4b..2f9d1605 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -701,3 +701,65 @@ def test_ciam_issuer_host_via_b2c_check(self, tenant_discovery_mock): self.assertTrue(authority.has_valid_issuer(), "Issuer ending with ciamlogin.com should be valid") + # Domain spoofing prevention tests + @patch("msal.authority.tenant_discovery") + def test_spoofed_b2c_host_should_be_rejected(self, tenant_discovery_mock): + """fakeb2clogin.com must NOT match b2clogin.com""" + authority_url = "https://custom-domain.com/tenant" + issuer = "https://fakeb2clogin.com/tenant" + tenant_discovery_mock.return_value = { + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "issuer": issuer, + } + with self.assertRaises(ValueError): + Authority(None, self.http_client, oidc_authority_url=authority_url) + + @patch("msal.authority.tenant_discovery") + def test_spoofed_b2c_host_with_prefix_should_be_rejected(self, tenant_discovery_mock): + """evilb2clogin.com must NOT match b2clogin.com""" + authority_url = "https://custom-domain.com/tenant" + issuer = "https://evilb2clogin.com/tenant" + tenant_discovery_mock.return_value = { + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "issuer": issuer, + } + with self.assertRaises(ValueError): + Authority(None, self.http_client, oidc_authority_url=authority_url) + + @patch("msal.authority.tenant_discovery") + def test_b2c_domain_used_as_subdomain_of_evil_site_should_be_rejected(self, tenant_discovery_mock): + """b2clogin.com.evil.com must NOT match b2clogin.com""" + authority_url = "https://custom-domain.com/tenant" + issuer = "https://b2clogin.com.evil.com/tenant" + tenant_discovery_mock.return_value = { + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "issuer": issuer, + } + with self.assertRaises(ValueError): + Authority(None, self.http_client, oidc_authority_url=authority_url) + + @patch("msal.authority.tenant_discovery") + def test_spoofed_ciamlogin_host_should_be_rejected(self, tenant_discovery_mock): + """fakeciamlogin.com must NOT match ciamlogin.com""" + authority_url = "https://custom-domain.com/tenant" + issuer = "https://fakeciamlogin.com/tenant" + tenant_discovery_mock.return_value = { + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "issuer": issuer, + } + with self.assertRaises(ValueError): + Authority(None, self.http_client, oidc_authority_url=authority_url) + + @patch("msal.authority.tenant_discovery") + def test_valid_b2c_subdomain_should_be_accepted(self, tenant_discovery_mock): + """login.b2clogin.com should match .b2clogin.com""" + authority_url = "https://custom-domain.com/tenant" + issuer = "https://login.b2clogin.com/tenant" + authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock) + self.assertTrue(authority.has_valid_issuer(), + "Legitimate subdomain of b2clogin.com should be valid") +