feat(m365): add entra_app_registration_redirect_uris_secure check (#12993)

Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:42:21 +02:00
1 parent f606a3c195
commit c438858dbd
8 files changed
+1074

No files matched your search

@@ -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
@@ -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"
@@ -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."
}
@@ -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
@@ -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] = []
@@ -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
@@ -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 == []