Same as the other one, keeping it light per SECURITY.md since private reporting isn't enabled.
execute_api_call in src/integrations.py checks the URL before sending:
ok, reason = check_outbound_url(url, block_private=block_private)
if not ok:
return {"error": f"URL rejected: {reason}", "exit_code": 1}
check_outbound_url does resolve the host and classify every address it gets back, that part is solid. But its signature is -> Tuple[bool, str], so the addresses it just validated don't come back to the caller. Then the request goes out on a default client:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(method, url, params=params, ..., headers=headers, auth=auth)
which resolves the host again when it opens the socket.
So there are two lookups and nothing connects them. Whatever the guard approved has no bearing on where the socket actually goes, as long as the first answer looked fine. By that point headers already has the integration's stored API key or bearer token on it. The agent can drive this through do_api_call in src/tools/system.py.
What makes me think this is just an oversight is that everywhere else in the tree already handles it:
services/search/content.py has _PinnedTransport, and does redirects manually so it can re-check each hop
src/webhook_manager.py has _PinnedAsyncBackend / _PinnedAsyncTransport
api_call got the check but not the pin, so it's the last outbound path still resolving twice.
Also worth flagging that test_hostname_resolving_to_metadata_ip_is_rejected doesn't cover this. It uses a host that statically resolves somewhere bad, so it passes either way, since it never triggers a second lookup. Easy to read that test as covering more than it does.
I've got a fix that reuses the same pinned-transport shape webhook delivery already uses, with tests including a real-socket one. Happy to PR it.
Same as the other one, keeping it light per SECURITY.md since private reporting isn't enabled.
execute_api_callinsrc/integrations.pychecks the URL before sending:check_outbound_urldoes resolve the host and classify every address it gets back, that part is solid. But its signature is-> Tuple[bool, str], so the addresses it just validated don't come back to the caller. Then the request goes out on a default client:which resolves the host again when it opens the socket.
So there are two lookups and nothing connects them. Whatever the guard approved has no bearing on where the socket actually goes, as long as the first answer looked fine. By that point
headersalready has the integration's stored API key or bearer token on it. The agent can drive this throughdo_api_callinsrc/tools/system.py.What makes me think this is just an oversight is that everywhere else in the tree already handles it:
services/search/content.pyhas_PinnedTransport, and does redirects manually so it can re-check each hopsrc/webhook_manager.pyhas_PinnedAsyncBackend/_PinnedAsyncTransportapi_call got the check but not the pin, so it's the last outbound path still resolving twice.
Also worth flagging that
test_hostname_resolving_to_metadata_ip_is_rejecteddoesn't cover this. It uses a host that statically resolves somewhere bad, so it passes either way, since it never triggers a second lookup. Easy to read that test as covering more than it does.I've got a fix that reuses the same pinned-transport shape webhook delivery already uses, with tests including a real-socket one. Happy to PR it.