Skip to content

Commit 4634610

Browse files
authored
FIX: Add destination allowlist before forwarding Bearer credential to Graph in EntraAuthMiddleware (microsoft#2183)
1 parent db81eec commit 4634610

2 files changed

Lines changed: 129 additions & 20 deletions

File tree

pyrit/backend/middleware/auth.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
from dataclasses import dataclass
2121
from typing import Any, ClassVar
22+
from urllib.parse import urlparse
2223

2324
import httpx
2425
import jwt
@@ -51,6 +52,13 @@ class EntraAuthMiddleware(BaseHTTPMiddleware):
5152
"/api/media",
5253
}
5354

55+
# Hosts the user's Bearer token may be forwarded to
56+
_GRAPH_HOSTS: ClassVar[set[str]] = {"graph.microsoft.com"}
57+
58+
# Trusted URL for resolving group membership; built from a constant (not the
59+
# token-supplied endpoint) so token data cannot control the request destination.
60+
_GRAPH_MEMBER_OBJECTS_URL: ClassVar[str] = "https://graph.microsoft.com/v1.0/me/getMemberObjects"
61+
5462
def __init__(self, app: ASGIApp) -> None:
5563
"""Initialize the middleware with Entra ID configuration from environment variables."""
5664
super().__init__(app)
@@ -167,34 +175,34 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
167175
"""
168176
Resolve group membership via Microsoft Graph when user is in >200 groups.
169177
170-
When a user is in >200 groups, Entra ID replaces the `groups` claim with
171-
`_claim_sources` containing a Graph API endpoint. This method calls the
172-
Microsoft Graph `getMemberObjects` endpoint to retrieve transitive group
173-
memberships, using the user's access token.
178+
When a user is in >200 groups, Entra ID replaces the `groups` claim with a
179+
`_claim_names` / `_claim_sources` overage pointer. This method confirms the
180+
overage is present, then calls the Microsoft Graph `getMemberObjects` endpoint
181+
to retrieve transitive group memberships, using the user's access token.
182+
183+
The Graph URL is built from a trusted constant (``_GRAPH_MEMBER_OBJECTS_URL``)
184+
rather than the token-supplied endpoint, so token data cannot control the
185+
request host, path, or port. Pagination links from the Graph response are
186+
additionally checked against the Graph allowlist (see ``_is_trusted_graph_url``).
174187
175188
Args:
176-
claims: The decoded JWT claims containing _claim_sources.
189+
claims: The decoded JWT claims containing the group overage pointer.
177190
token: The raw Bearer token to forward to Graph API.
178191
179192
Returns:
180-
List of group IDs the user belongs to, or empty list on failure.
193+
List of group IDs the user belongs to, or empty list on failure or
194+
when no group overage is present.
181195
"""
182196
try:
183-
claim_sources = claims.get("_claim_sources", {})
184-
src = claim_sources.get("src1", {})
185-
endpoint = src.get("endpoint", "")
186-
187-
if not endpoint:
188-
logger.debug("No group resolution endpoint found in _claim_sources")
197+
# Entra signals a groups overage via `_claim_names` -> source key -> `_claim_sources`.
198+
# We only confirm the overage is present; the endpoint it supplies is intentionally
199+
# ignored in favor of a trusted constant so token data cannot control the request.
200+
claim_source_key = claims.get("_claim_names", {}).get("groups")
201+
if not claim_source_key or claim_source_key not in claims.get("_claim_sources", {}):
202+
logger.debug("No group overage claim source found")
189203
return []
190204

191-
# The _claim_sources endpoint may be a legacy graph.windows.net URL.
192-
# Rewrite to Microsoft Graph (graph.microsoft.com) which is the
193-
# supported API. The legacy Azure AD Graph was retired in 2023.
194-
if "graph.windows.net" in endpoint:
195-
# Legacy format: https://graph.windows.net/{tenant}/users/{oid}/getMemberObjects
196-
# Graph format: https://graph.microsoft.com/v1.0/me/getMemberObjects
197-
endpoint = "https://graph.microsoft.com/v1.0/me/getMemberObjects"
205+
endpoint = self._GRAPH_MEMBER_OBJECTS_URL
198206

199207
all_group_ids: list[str] = []
200208
async with httpx.AsyncClient() as client:
@@ -222,6 +230,9 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
222230
# Handle pagination — Graph may return @odata.nextLink for large results
223231
next_link = data.get("@odata.nextLink")
224232
while next_link:
233+
if not self._is_trusted_graph_url(next_link):
234+
logger.warning("Refusing to follow untrusted pagination link: %s", next_link)
235+
break
225236
response = await client.get(
226237
next_link,
227238
headers={"Authorization": f"Bearer {token}"},
@@ -241,6 +252,16 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
241252
logger.warning("Failed to resolve group memberships: %s", e)
242253
return []
243254

255+
def _is_trusted_graph_url(self, url: str) -> bool:
256+
"""
257+
Whether *url* is an HTTPS Microsoft Graph URL safe to forward the token to.
258+
259+
Returns:
260+
True if *url* uses HTTPS and its host is in the Graph allowlist, False otherwise.
261+
"""
262+
parsed = urlparse(url)
263+
return parsed.scheme == "https" and parsed.hostname in self._GRAPH_HOSTS
264+
244265
def _validate_token(self, token: str) -> tuple[AuthenticatedUser | None, dict[str, Any]]:
245266
"""
246267
Validate a JWT against Entra ID JWKS.

tests/unit/backend/test_auth_middleware.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55
Tests for the Entra ID auth middleware.
66
"""
77

8-
from unittest.mock import MagicMock, patch
8+
from unittest.mock import AsyncMock, MagicMock, patch
9+
10+
import pytest
911

1012
from pyrit.backend.middleware.auth import EntraAuthMiddleware
1113

1214

15+
def _make_middleware() -> EntraAuthMiddleware:
16+
with patch.dict("os.environ", {"ENTRA_TENANT_ID": "", "ENTRA_CLIENT_ID": ""}, clear=False):
17+
return EntraAuthMiddleware(MagicMock())
18+
19+
1320
def test_validate_token_returns_none_when_jwks_client_is_none():
1421
"""Test that _validate_token returns (None, {}) when _jwks_client is None."""
1522
mock_app = MagicMock()
@@ -27,3 +34,84 @@ def test_validate_token_returns_none_when_jwks_client_is_none():
2734

2835
assert user is None
2936
assert claims == {}
37+
38+
39+
@pytest.mark.parametrize(
40+
"url, expected",
41+
[
42+
("https://graph.microsoft.com/v1.0/me/getMemberObjects", True),
43+
("https://graph.microsoft.com/v1.0/me/memberOf?$skiptoken=abc", True),
44+
("http://graph.microsoft.com/v1.0/me/getMemberObjects", False), # not https
45+
("https://evil.com/v1.0/me/getMemberObjects", False), # wrong host
46+
("https://graph.microsoft.com.evil.com/x", False), # suffix spoof
47+
("https://graph.microsoft.com@evil.com/x", False), # userinfo spoof
48+
("", False),
49+
],
50+
)
51+
def test_is_trusted_graph_url(url, expected):
52+
"""Only HTTPS Microsoft Graph hosts are trusted to receive the forwarded token."""
53+
assert _make_middleware()._is_trusted_graph_url(url) is expected
54+
55+
56+
async def test_resolve_excess_groups_no_overage_returns_empty():
57+
"""Claims without a groups overage pointer return [] and make no HTTP request."""
58+
middleware = _make_middleware()
59+
claims = {"_claim_sources": {"src1": {"endpoint": "https://graph.microsoft.com/x"}}} # no _claim_names.groups
60+
61+
with patch("pyrit.backend.middleware.auth.httpx.AsyncClient") as mock_client:
62+
result = await middleware._resolve_excess_groups_async(claims, "the-token")
63+
64+
assert result == []
65+
mock_client.assert_not_called()
66+
67+
68+
async def test_resolve_excess_groups_ignores_token_endpoint():
69+
"""The Graph request uses the trusted constant URL, never the token-supplied endpoint."""
70+
middleware = _make_middleware()
71+
claims = {
72+
"_claim_names": {"groups": "src1"},
73+
"_claim_sources": {"src1": {"endpoint": "https://evil.com/steal"}},
74+
}
75+
76+
mock_response = MagicMock()
77+
mock_response.status_code = 200
78+
mock_response.json.return_value = {"value": ["group-1", "group-2"]}
79+
80+
mock_client = AsyncMock()
81+
mock_client.post.return_value = mock_response
82+
mock_client_cm = MagicMock()
83+
mock_client_cm.__aenter__ = AsyncMock(return_value=mock_client)
84+
mock_client_cm.__aexit__ = AsyncMock(return_value=False)
85+
86+
with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=mock_client_cm):
87+
result = await middleware._resolve_excess_groups_async(claims, "the-token")
88+
89+
assert result == ["group-1", "group-2"]
90+
posted_url = mock_client.post.call_args.args[0]
91+
assert posted_url == EntraAuthMiddleware._GRAPH_MEMBER_OBJECTS_URL
92+
assert "evil.com" not in posted_url
93+
94+
95+
async def test_resolve_excess_groups_stops_on_untrusted_pagination_link():
96+
"""An untrusted @odata.nextLink halts pagination without issuing the follow-up GET."""
97+
middleware = _make_middleware()
98+
claims = {
99+
"_claim_names": {"groups": "src1"},
100+
"_claim_sources": {"src1": {"endpoint": "https://graph.microsoft.com/x"}},
101+
}
102+
103+
mock_response = MagicMock()
104+
mock_response.status_code = 200
105+
mock_response.json.return_value = {"value": ["group-1"], "@odata.nextLink": "https://evil.com/next"}
106+
107+
mock_client = AsyncMock()
108+
mock_client.post.return_value = mock_response
109+
mock_client_cm = MagicMock()
110+
mock_client_cm.__aenter__ = AsyncMock(return_value=mock_client)
111+
mock_client_cm.__aexit__ = AsyncMock(return_value=False)
112+
113+
with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=mock_client_cm):
114+
result = await middleware._resolve_excess_groups_async(claims, "the-token")
115+
116+
assert result == ["group-1"]
117+
mock_client.get.assert_not_called()

0 commit comments

Comments
 (0)