Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions server/src/flowforge_server/services/network_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ def validate_webhook_url(url: str) -> None:
resolve_and_validate_host(hostname, parsed.port)


def _check_redirect(response: httpx.Response) -> None:
"""httpx event hook: validate redirect targets against SSRF."""
def _check_redirect_impl(response: httpx.Response) -> None:
"""SSRF check applied to every response. Raises ValueError on a
redirect whose target resolves to a private/reserved address."""
if response.is_redirect and "location" in response.headers:
target = response.headers["location"]
target_url = response.url.join(target)
Expand All @@ -72,12 +73,30 @@ def _check_redirect(response: httpx.Response) -> None:
resolve_and_validate_host(hostname, target_url.port)


async def _async_check_redirect(response: httpx.Response) -> None:
"""Async event-hook wrapper for ``AsyncClient``. httpx awaits the
return of every async-client event hook; a plain sync function
returns ``None`` and triggers ``TypeError: object NoneType can't be
used in 'await' expression`` on every webhook-tool invocation."""
_check_redirect_impl(response)


def _sync_check_redirect(response: httpx.Response) -> None:
"""Sync event-hook wrapper for the sync ``Client``."""
_check_redirect_impl(response)


# Public alias kept for any external callers that referenced the
# pre-fix sync function name.
_check_redirect = _sync_check_redirect


def create_ssrf_safe_client(**kwargs: object) -> httpx.AsyncClient:
"""Create an async httpx client that validates redirect targets."""
kwargs.setdefault("follow_redirects", True)
kwargs.setdefault("timeout", 30.0)
return httpx.AsyncClient(
event_hooks={"response": [_check_redirect]}, **kwargs # type: ignore[arg-type]
event_hooks={"response": [_async_check_redirect]}, **kwargs # type: ignore[arg-type]
)
Comment on lines 94 to 100
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a regression test to cover the async event-hook path (the original bug was await None when a sync hook was registered on AsyncClient). You can unit-test this without real network by using httpx.MockTransport with an AsyncClient created via create_ssrf_safe_client() and asserting a request/redirect does not raise TypeError and still enforces the SSRF redirect validation.

Copilot uses AI. Check for mistakes.


Expand All @@ -86,5 +105,5 @@ def create_ssrf_safe_sync_client(**kwargs: object) -> httpx.Client:
kwargs.setdefault("follow_redirects", True)
kwargs.setdefault("timeout", 30.0)
return httpx.Client(
event_hooks={"response": [_check_redirect]}, **kwargs # type: ignore[arg-type]
event_hooks={"response": [_sync_check_redirect]}, **kwargs # type: ignore[arg-type]
)
Loading