fix(ssrf): use async event-hook for AsyncClient SSRF redirect check#39
Conversation
`_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.
There was a problem hiding this comment.
Pull request overview
Fixes SSRF-safe httpx client behavior by ensuring the redirect-check response event hook is compatible with httpx.AsyncClient (async hooks must be awaitable), preventing webhook tool invocations from failing with TypeError: object NoneType can't be used in 'await' expression.
Changes:
- Refactors redirect validation into
_check_redirect_implwith dedicated async/sync wrappers. - Registers
_async_check_redirectforhttpx.AsyncClientand_sync_check_redirectforhttpx.Client. - Keeps
_check_redirectas an alias to the sync wrapper for backwards compatibility.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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] | ||
| ) |
There was a problem hiding this comment.
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.
Summary
_check_redirectis a plaindefregistered as a response event hook on bothhttpx.Client(sync) andhttpx.AsyncClient(async). httpx awaits the return value of every async-client event hook on every response; a sync function returnsNone, andawait Noneraises:This fires on every webhook-tool invocation through the inline executor:
The exception bubbles up as the LLM's tool-result string
"Error executing tool: object NoneType can't be used in 'await' expression", so the agent never gets a real answer from any webhook tool and the inline pipeline stalls / fails.This blocks any inline function whose tools are
tool_type='webhook'.Reproduce on main
Fix
Split
_check_redirectinto two wrappers —_async_check_redirect(async) and_sync_check_redirect(sync) — over a shared_check_redirect_impl. Register the async one onAsyncClientand the sync one onClient. The original_check_redirectname is kept as an alias to the sync version for backward compatibility with any external callers.Test plan
_check_redirect_implunchanged behaviour for redirects (still raisesValueErrorwhen target resolves to a private IP)Clientfromcreate_ssrf_safe_sync_clientstill worksAsyncClientfromcreate_ssrf_safe_clientand callshttpbin.orgto verify no event-hook error