From 8a09f2c23aa6c8dfe5b64d2e637d7359c0fcef82 Mon Sep 17 00:00:00 2001 From: Hootan Moradi Date: Wed, 29 Apr 2026 00:50:03 +0200 Subject: [PATCH] fix(ssrf): use async event-hook for AsyncClient SSRF redirect check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_check_redirect` was a plain `def` registered as a response event hook on both the sync `httpx.Client` and the async `httpx.AsyncClient`. httpx awaits the return value of every async- client event hook on every response; a sync function returns `None`, and `await None` raises: TypeError: object NoneType can't be used in 'await' expression That fired on every webhook tool call from the inline executor: agent → InlineExecutor._execute_webhook_tool → AsyncClient.request(...) → _check_redirect(response) returns None → httpx awaits None → TypeError The exception bubbles up as the tool result string "Error executing tool: object NoneType can't be used in 'await' expression", so every webhook tool returns an error to the LLM and the agent never makes progress. Inline functions with webhook tools were unusable. Reproduce on main: POST /api/v1/tools (tool_type=webhook, any public URL) POST /api/v1/functions/inline (system_prompt + ["that_tool"]) POST /api/v1/events (matching trigger, with prompt field) The agent's first tool call returns the NoneType error; the run either fails or the agent gives up. Fix: split into `_async_check_redirect` (async wrapper) and `_sync_check_redirect` (sync wrapper) over a shared sync impl, register the right shape on the right client. The original `_check_redirect` symbol is kept as an alias to the sync version for any external callers. --- .../services/network_utils.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/server/src/flowforge_server/services/network_utils.py b/server/src/flowforge_server/services/network_utils.py index ec7de4a..fc87083 100644 --- a/server/src/flowforge_server/services/network_utils.py +++ b/server/src/flowforge_server/services/network_utils.py @@ -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) @@ -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] ) @@ -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] )