Restrict MSGraph deferrable pagination to the configured host - #71842
Restrict MSGraph deferrable pagination to the configured host#71842FrankYang0529 wants to merge 1 commit into
Conversation
c8f00af to
cdde34f
Compare
dabla
left a comment
There was a problem hiding this comment.
This PR addresses a genuine CWE-918 (SSRF) risk: when MSGraphAsyncOperator.trigger_next_link() fires a new MSGraphTrigger for the next page, the url comes directly from the previous API response's @odata.nextLink. Without a host check the Kiota adapter would attach the bearer token to whatever host the response contained.
The approach — adding pagination_link: bool to the trigger, propagating it through serialize(), and calling a centralised assert_allowed_host() before the request — is clean and correct for the trigger path.
Two points worth addressing before merge:
-
Configurability gap (inline comment on hooks/msgraph.py): the single-endpoint check is correct for normal use (SharePoint/Power BI callers configure
hostto match, so pagination links pass). The gap is when a caller passes an absoluteurlthat differs from the connection’shost, or uses a custompagination_functionreturning absolute URLs for a different target. Makingallowed_netlocsan explicit opt-in set fixes this at no security cost. The reviewer has working local code; a ready-to-use snippet is in the inline comment. -
Test isolation (inline comment on test_msgraph.py):
test_pagination_refuses_cross_host_next_linkvalidates the full end-to-end path but hides the exception-wrapping mechanics. A direct unit test ofassert_allowed_host()on the hook would make the contract explicit and survive any future changes to the trigger’s exception handler.
The nit on hooks/msgraph.py is a minor refactoring suggestion (making the check synchronous) that follows naturally if point 1 is adopted.
Drafted-by: Claude Sonnet 4.6 (claude-sonnet-4.6); reviewed by @dabla before posting
Signed-off-by: PoAn Yang <payang@apache.org>
cdde34f to
db1a33a
Compare
|
|
||
| return response | ||
|
|
||
| async def assert_allowed_host(self, url: str | None) -> None: |
There was a problem hiding this comment.
I would prefer having an implementation looking like this. Instead of adding allowed_netlocs, I would keep the existing allowed_netloc + dynamically derive the allowed_hosts from the RequestAdapter. Advantage of re-using this is that everything is already setup in the connection and we don't have to do duplicate configuration.
So I was thinking something like this.
A method which resolves the allowed_hosts from the RequestAdapter:
async def get_allowed_hosts(self) -> set[str]:
request_adapter = await self.get_async_conn()
return set(request_adapter._authentication_provider.access_token_provider.allowed_hosts)
Some casts will probably be needed by mypy for this one.
Then we adapt the extracted assert_allowed_host method (I really like this refactoring you did):
async def assert_allowed_host(self, url: str | None) -> None:
"""
Refuse an absolute ``url`` whose host the connection does not allow.
A pagination link (e.g. ``@odata.nextLink``) is echoed from the API response and is re-fetched
with the connection's bearer token attached. That token is withheld only from hosts outside
``allowed_hosts``, which defaults to empty (any host) unless configured, so a tampered response
could send it to an arbitrary host (CWE-918).
"""
if not url or not url.startswith("http"):
return
allowed_hosts = await self.get_allowed_hosts() | self.allowed_netloc
if urlparse(url).netloc.lower() not in allowed_hosts:
raise ValueError(
f"Refusing to follow pagination link {url!r}: its host is not among the allowed "
f"Microsoft Graph endpoints {sorted(allowed_hosts)}."
)
Something like this, let me know what you think about it.
Why
MSGraphAsyncOperatorfetches the next page, the url comes from the previous response. The Kiota adapter attaches the connection's access token to every request it sends. Nothing checks that url before the request goes out.How
paginated_run()already does (added in Restrict MSGraph pagination nextLink to the configured host #69742) intoKiotaRequestAdapterHook.assert_allowed_host(). The allowed host comes from the request adapter'sbase_url.pagination_link: bool = FalsetoMSGraphTrigger, carry it throughserialize(), and call the helper at the top ofrun()when it is set.MSGraphAsyncOperator.trigger_next_link()passespagination_link=True, so the check applies only to urls that came back from a response.$top/$skipbranch and a custompagination_functioncan both produce one, soassert_allowed_host()returns early for any url that is not absolute.Verification
uv run --project providers/microsoft/azure pytest providers/microsoft/azure/tests/unit/microsoft/azure -k msgraphWas generative AI tooling used to co-author this PR?
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.