From c438858dbd276cd7543a68d2236aaca313b88801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Molina=20Kinomakino?= Date: Tue, 13 Oct 2026 15:42:21 +0200 Subject: [PATCH] feat(m365): add entra_app_registration_redirect_uris_secure check (#12993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Pedro Martín --- .../entra-app-redirect-uris-secure.added.md | 1 + .../compliance/m365/iso27001_2022_m365.json | 2 + .../__init__.py | 0 ...tration_redirect_uris_secure.metadata.json | 39 + ...a_app_registration_redirect_uris_secure.py | 165 ++++ .../m365/services/entra/entra_service.py | 30 + ..._registration_redirect_uris_secure_test.py | 783 ++++++++++++++++++ .../entra/microsoft365_entra_service_test.py | 54 ++ 8 files changed, 1074 insertions(+) create mode 100644 prowler/changelog.d/entra-app-redirect-uris-secure.added.md create mode 100644 prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py create mode 100644 tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py diff --git a/prowler/changelog.d/entra-app-redirect-uris-secure.added.md b/prowler/changelog.d/entra-app-redirect-uris-secure.added.md new file mode 100644 index 0000000000..0301b96983 --- /dev/null +++ b/prowler/changelog.d/entra-app-redirect-uris-secure.added.md @@ -0,0 +1 @@ +`entra_app_registration_redirect_uris_secure` check for M365 provider, flagging app registrations with HTTP, wildcard, malformed or `azurewebsites.net` redirect URIs diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index e3a6e873ca..f6e5fa1cec 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -174,6 +174,7 @@ } ], "Checks": [ + "entra_app_registration_redirect_uris_secure", "entra_conditional_access_policy_app_enforced_restrictions", "exchange_transport_config_smtp_auth_disabled", "exchange_transport_rules_mail_forwarding_disabled", @@ -927,6 +928,7 @@ "admincenter_users_admins_reduced_license_footprint", "defender_malware_policy_comprehensive_attachments_filter_applied", "entra_app_registration_no_unused_privileged_permissions", + "entra_app_registration_redirect_uris_secure", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "sharepoint_modern_authentication_required" diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json new file mode 100644 index 0000000000..d75131d6b5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_app_registration_redirect_uris_secure", + "CheckTitle": "Secure redirect URIs protect application registration tokens from interception and theft", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Entra ID **application registrations** must use **secure redirect URIs**. This check flags web, SPA and public-client redirect URIs that use plain `http://` on non-loopback hosts, reside on shared platform domains like `azurewebsites.net`, contain wildcards, or are malformed.\n\nTokens are delivered to redirect URIs, so insecure URIs expose them to interception or theft.", + "Risk": "An insecure redirect URI allows an attacker to **intercept authorization codes and tokens** in transit (plain HTTP), **redirect them** to a host they control (wildcard or malformed URI), or **claim a dangling subdomain** on a shared platform domain like `azurewebsites.net` to receive valid tokens for the application's users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity-platform/reply-url", + "https://learn.microsoft.com/azure/security/fundamentals/subdomain-takeover", + "https://learn.microsoft.com/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app update --id \"$APP_ID\" --web-redirect-uris \"https://$OWNED_DOMAIN/signin-oidc\"", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Applications > App registrations\n3. Select the flagged application\n4. Go to Authentication\n5. Replace any `http://` redirect URIs with `https://`\n6. Remove any wildcard URIs and unused or malformed URIs\n7. Move applications off `*.azurewebsites.net` onto a custom domain you own\n8. Delete redirect URIs whose domain no longer resolves in DNS", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use only `https://` redirect URIs pointing to domains you own and control. Remove **wildcard** entries, replace shared platform domains with **custom domains**, and clean up unused or malformed URIs to prevent token theft via redirect manipulation or subdomain takeover.", + "Url": "https://hub.prowler.com/check/entra_app_registration_redirect_uris_secure" + } + }, + "Categories": [ + "internet-exposed", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DNS resolution checks for dangling domains are not performed by this check due to network egress requirements, non-deterministic results, and false positives on internal-only hostnames. Custom URI schemes used by public clients (msal://, ms-appx-web://, brk-://, urn:ietf:wg:oauth:2.0:oob, com.company.app://) are excluded from validation. Loopback hosts (localhost, 127.0.0.1, [::1]) are allowed over plain HTTP per Microsoft's platform behaviour." +} diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py new file mode 100644 index 0000000000..c86b6c9b0a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py @@ -0,0 +1,165 @@ +"""Check that application registration redirect URIs are secure.""" + +from urllib.parse import urlparse + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Custom URI schemes used by public clients that are valid and must not be +# flagged as malformed or checked for DNS resolution. +_PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES = ( + "msal", + "ms-appx-web", + "brk-", + "com.", + "msauth.", +) + +# Hosts considered loopback and therefore allowed over plain HTTP. +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + +# Platform domains susceptible to subdomain takeover. +_PLATFORM_DOMAINS = (".azurewebsites.net",) + + +def _is_custom_scheme_uri(uri: str) -> bool: + """Return True if the URI uses a known custom scheme for public clients. + + Args: + uri: The redirect URI string. + + Returns: + True when the scheme is a recognised custom/native scheme that should + not be validated further. + """ + if ":" not in uri: + return False + # Compare against the scheme only, never against the rest of the URI. + scheme = uri.split(":", 1)[0].lower() + if scheme == "urn": + return True + return any( + scheme.startswith(prefix) for prefix in _PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES + ) + + +def _check_uri(uri: str) -> str | None: + """Evaluate a single redirect URI for insecure patterns. + + Args: + uri: The redirect URI string. + + Returns: + A reason string when the URI is insecure, or None when it is safe. + """ + # Wildcards are never legitimate, not even in native schemes, so this + # runs first (and before parsing, since wildcards may break urlparse). + if "*" in uri: + return "contains wildcard" + + # Skip recognised custom schemes (public-client / native). + if _is_custom_scheme_uri(uri): + return None + + try: + parsed = urlparse(uri) + except Exception: + return "malformed URI" + + scheme = (parsed.scheme or "").lower() + host = (parsed.hostname or "").lower() + + # A URI that has no scheme or host is malformed. + if not scheme or not host: + return "malformed URI" + + # urlparse accepts invalid ports; reading .port is what validates them. + try: + parsed.port + except ValueError: + return "malformed URI" + + # Plain HTTP with a non-loopback host. + if scheme == "http" and host not in _LOOPBACK_HOSTS: + return "uses http:// with non-localhost host" + + # Platform domain susceptible to subdomain takeover. + for domain in _PLATFORM_DOMAINS: + if host == domain.lstrip(".") or host.endswith(domain): + return ( + f"host on shared platform domain {domain.lstrip('.')}, " + "subdomain takeover risk if the App Service is removed" + ) + + return None + + +class entra_app_registration_redirect_uris_secure(Check): + """Ensure application registration redirect URIs are secure. + + This check evaluates every application registration in the tenant and + inspects its web, SPA and public-client redirect URIs for insecure + patterns: plain HTTP on non-loopback hosts, hosts on shared platform + domains (e.g. azurewebsites.net), wildcard characters, and malformed URIs. + + - PASS: None of the application's redirect URIs match a FAIL condition, + or the application has no redirect URIs configured. + - FAIL: At least one redirect URI is insecure; every offending URI and its + reason are listed in ``status_extended``. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the redirect URI security check. + + Returns: + A list of reports containing the result of the check. + """ + findings: list[CheckReportM365] = [] + + for app_id, app in entra_client.app_registrations.items(): + display_name = app.name or app.app_id + report = CheckReportM365( + metadata=self.metadata(), + resource=app, + resource_name=display_name, + resource_id=app_id, + ) + + # Collect all redirect URIs across platforms. + all_uris = ( + app.web_redirect_uris + + app.spa_redirect_uris + + app.public_client_redirect_uris + ) + + # Evaluate each URI. + insecure: list[str] = [] + for uri in all_uris: + reason = _check_uri(uri) + if reason: + insecure.append(f"{uri} ({reason})") + + if insecure: + report.status = "FAIL" + total = len(insecure) + if total > 5: + displayed = ", ".join(insecure[:5]) + displayed += f" (and {total - 5} more)" + else: + displayed = ", ".join(insecure) + report.status_extended = ( + f"App registration {display_name} has {total} insecure " + f"redirect URI(s): {displayed}." + ) + else: + report.status = "PASS" + if all_uris: + report.status_extended = ( + f"App registration {display_name} has all redirect URIs secure." + ) + else: + report.status_extended = f"App registration {display_name} has no redirect URIs configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 4ecb2b1126..7f124ff0b4 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -2119,11 +2119,34 @@ OAuthAppInfo ) ) + # Extract redirect URIs from web, spa and publicClient + web_obj = getattr(app, "web", None) + web_redirect_uris = ( + list(getattr(web_obj, "redirect_uris", []) or []) + if web_obj + else [] + ) + spa_obj = getattr(app, "spa", None) + spa_redirect_uris = ( + list(getattr(spa_obj, "redirect_uris", []) or []) + if spa_obj + else [] + ) + public_client_obj = getattr(app, "public_client", None) + public_client_redirect_uris = ( + list(getattr(public_client_obj, "redirect_uris", []) or []) + if public_client_obj + else [] + ) + app_registrations[object_id] = AppRegistration( id=object_id, app_id=app_id, name=getattr(app, "display_name", "") or "", password_credentials=password_credentials, + web_redirect_uris=web_redirect_uris, + spa_redirect_uris=spa_redirect_uris, + public_client_redirect_uris=public_client_redirect_uris, ) next_link = getattr(app_response, "odata_next_link", None) @@ -2935,9 +2958,16 @@ class AppRegistration(BaseModel): name: The application's display name. password_credentials: List of password credentials (client secrets) registered on the application. + web_redirect_uris: Redirect URIs configured for the web platform. + spa_redirect_uris: Redirect URIs configured for the SPA platform. + public_client_redirect_uris: Redirect URIs configured for the + public client (mobile/desktop) platform. """ id: str app_id: str = "" name: str = "" password_credentials: List[PasswordCredential] = [] + web_redirect_uris: List[str] = [] + spa_redirect_uris: List[str] = [] + public_client_redirect_uris: List[str] = [] diff --git a/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py new file mode 100644 index 0000000000..7663165af1 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py @@ -0,0 +1,783 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import AppRegistration +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_app_registration_redirect_uris_secure: + """Tests for the entra_app_registration_redirect_uris_secure check.""" + + def test_no_app_registrations(self): + """No app registrations in tenant: no findings.""" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 0 + + def test_app_no_redirect_uris(self): + """App with no redirect URIs configured: expected PASS.""" + app_id = str(uuid4()) + app_name = "Clean App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "no redirect URIs configured" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_all_secure_uris(self): + """App with only secure redirect URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Secure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://portal.contoso.com/signin-oidc", + "https://app.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth" + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "all redirect URIs secure" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_non_localhost_uri(self): + """App with an http:// URI on a non-localhost host: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Http App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + assert "portal.contoso.com" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_localhost_allowed(self): + """App with http://localhost: expected PASS (loopback is allowed).""" + app_id = str(uuid4()) + app_name = "Localhost App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://localhost:3000", + "http://127.0.0.1:8080", + "http://[::1]:5000", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + + def test_app_with_azurewebsites_uri(self): + """App with a host on *.azurewebsites.net: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Azure Web App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://legacy-portal.azurewebsites.net/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_bare_azurewebsites_domain(self): + """App with host exactly azurewebsites.net (no subdomain): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Bare Domain App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://azurewebsites.net/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + + def test_app_with_wildcard_uri(self): + """App with a wildcard in the redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://*.contoso.com/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + assert result[0].resource_id == app_id + + def test_app_with_malformed_uri_no_scheme(self): + """App with a malformed URI (no scheme): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Malformed App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "just-a-path/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_malformed_uri_no_host(self): + """App with a malformed URI (scheme but no host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "No Host App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https:///callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + + def test_app_with_custom_scheme_public_client(self): + """App with custom scheme public-client URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Native App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth", + "ms-appx-web://Microsoft.AAD.BrokerPlugin/1234", + "brk-multihub://contoso", + "urn:ietf:wg:oauth:2.0:oob", + "com.company.app://callback", + "msauth.com.contoso.app://auth", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_multiple_insecure_uris(self): + """App with multiple insecure redirect URIs: expected FAIL listing all.""" + app_id = str(uuid4()) + app_name = "Legacy Portal" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + "https://legacy-portal.azurewebsites.net/signin-oidc", + "https://*.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 insecure redirect URI(s)" in result[0].status_extended + assert result[0].resource_name == app_name + + def test_multiple_apps_mixed_results(self): + """Multiple apps: one secure, one insecure.""" + app_id_pass = str(uuid4()) + app_name_pass = "Secure App" + app_id_fail = str(uuid4()) + app_name_fail = "Insecure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id_pass: AppRegistration( + id=app_id_pass, + app_id=str(uuid4()), + name=app_name_pass, + web_redirect_uris=["https://app.contoso.com/callback"], + ), + app_id_fail: AppRegistration( + id=app_id_fail, + app_id=str(uuid4()), + name=app_name_fail, + web_redirect_uris=[ + "http://app.insecure.com/callback", + ], + ), + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 2 + result_pass = next(r for r in result if r.resource_id == app_id_pass) + result_fail = next(r for r in result if r.resource_id == app_id_fail) + assert result_pass.status == "PASS" + assert result_pass.resource_name == app_name_pass + assert result_fail.status == "FAIL" + assert result_fail.resource_name == app_name_fail + + def test_app_with_more_than_five_insecure_uris_truncated(self): + """App with more than 5 insecure URIs: status_extended is truncated.""" + app_id = str(uuid4()) + app_name = "Many URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(7)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7 insecure redirect URI(s)" in result[0].status_extended + assert "(and 2 more)" in result[0].status_extended + + def test_app_with_exactly_five_insecure_uris_no_truncation(self): + """App with exactly 5 insecure URIs: all displayed, no truncation.""" + app_id = str(uuid4()) + app_name = "Five URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(5)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "5 insecure redirect URI(s)" in result[0].status_extended + assert "(and" not in result[0].status_extended + + def test_app_with_wildcard_in_path(self): + """App with a wildcard in the path (not host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard Path App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://contoso.com/*/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + + def test_app_with_insecure_spa_redirect_uri(self): + """App with insecure SPA redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Insecure SPA" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://app.contoso.com:4200", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + + def test_app_resource_name_falls_back_to_app_id(self): + """App with no display name: resource_name falls back to app_id.""" + app_id = str(uuid4()) + app_app_id = str(uuid4()) + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=app_app_id, + name="", + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == app_app_id + assert f"App registration {app_app_id} has" in result[0].status_extended + + def test_check_uri_edge_cases(self): + """Edge cases for the redirect URI evaluation helper.""" + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + _check_uri, + ) + + # Loopback hosts are allowed over plain http. + assert _check_uri("http://localhost:3000/callback") is None + assert _check_uri("http://127.0.0.1:8080/callback") is None + assert _check_uri("http://[::1]:8080/callback") is None + # Wildcards are flagged even in native schemes. + assert _check_uri("com.contoso.app://*") == "contains wildcard" + # Prefixes only exempt the scheme, not any string that starts with them. + assert _check_uri("msalfoo.example.com/callback") == "malformed URI" + assert _check_uri("msal11111111-1111-1111-1111-111111111111://auth") is None + assert _check_uri("urn:ietf:wg:oauth:2.0:oob") is None + # Invalid ports make the URI malformed. + assert ( + _check_uri("https://example.com:notaport/callback") == "malformed URI" + ) + assert _check_uri("https://example.com:99999/callback") == "malformed URI" + # azurewebsites.net explains when the takeover risk applies. + reason = _check_uri("https://contoso.azurewebsites.net/signin-oidc") + assert "subdomain takeover risk if the App Service is removed" in reason diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 04c874a67a..86462d4902 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -2019,3 +2019,57 @@ class TestAuthorizationPolicyMsolMapping: policy = asyncio.run(service._get_authorization_policy()) assert policy.block_msol_powershell is value + + +class TestAppRegistrationRedirectUris: + def test_redirect_uris_are_read_for_every_platform(self): + app = SimpleNamespace( + id="obj-1", + app_id="app-1", + display_name="Portal", + password_credentials=[], + key_credentials=[], + web=SimpleNamespace(redirect_uris=["https://portal.contoso.com/signin"]), + spa=SimpleNamespace(redirect_uris=["http://localhost:3000"]), + public_client=SimpleNamespace(redirect_uris=["msalapp-1://auth"]), + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-1"].web_redirect_uris == ["https://portal.contoso.com/signin"] + assert apps["obj-1"].spa_redirect_uris == ["http://localhost:3000"] + assert apps["obj-1"].public_client_redirect_uris == ["msalapp-1://auth"] + + def test_missing_platforms_give_empty_lists(self): + app = SimpleNamespace( + id="obj-2", + app_id="app-2", + display_name="Daemon", + password_credentials=[], + key_credentials=[], + web=None, + spa=None, + public_client=None, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-2"].web_redirect_uris == [] + assert apps["obj-2"].spa_redirect_uris == [] + assert apps["obj-2"].public_client_redirect_uris == []