Skip to content

fix(integrations): pin api_call to the SSRF-validated IP#8

Closed
ashvinctrl wants to merge 1 commit into
odysseus-dev:devfrom
ashvinctrl:fix/integration-api-call-pin-rebinding-v2
Closed

fix(integrations): pin api_call to the SSRF-validated IP#8
ashvinctrl wants to merge 1 commit into
odysseus-dev:devfrom
ashvinctrl:fix/integration-api-call-pin-rebinding-v2

Conversation

@ashvinctrl

Copy link
Copy Markdown

Summary

execute_api_call validates the URL with check_outbound_url, which resolves the host and classifies every address it gets back. But that function returns Tuple[bool, str], so the addresses it validated never reach the caller. The request then goes out on a default httpx.AsyncClient, which resolves the host again when it opens the socket.

Two independent lookups, nothing tying them together. The guard's answer has no bearing on where the socket ends up, as long as the first answer looked acceptable. By then headers already carries the integration's stored API key or bearer token. Reachable from the agent through do_api_call in src/tools/system.py.

Everywhere else already closes this. services/search/content.py has _PinnedTransport plus manual redirect handling that re-checks each hop, and src/webhook_manager.py has _PinnedAsyncBackend / _PinnedAsyncTransport. api_call had the validation half and not the pin.

So: wrap the resolver so the guard's own resolution gets captured, then send the request through a pinned transport bound to those addresses.

A few details worth calling out, mostly things I got wrong on the first pass and had to fix:

Connect walks the approved addresses in order instead of taking only the first, otherwise a host whose first A record happens to be dead fails the whole call even though the guard approved several. Every address came out of the same resolution, so this is normal multi-address fallback and not a second lookup. The attempts share one deadline, so N dead addresses can't turn a 30s connect budget into N × 30s.

Only the socket destination is pinned. httpcore takes SNI and the Host header from the request URL rather than the connect address, so certificate validation and vhost routing still point at the real hostname.

The pool builds its SSL context with httpx.create_ssl_context() rather than ssl.create_default_context(). Those aren't the same thing: the httpx one gives you certifi plus SSL_CERT_FILE/SSL_CERT_DIR when trust_env is set, which is what the default client would have used. The stdlib one quietly switches you to system roots, which would be a behaviour change nobody asked for.

httpcore's exceptions get mapped back to their httpx equivalents so the existing except httpx.* blocks further down behave the way they already did.

I kept the transport local to the module instead of importing webhook_manager's, matching how web fetch and webhook delivery each carry their own. Didn't want api_call to start depending on the webhook subsystem for this.

Redirects aren't an issue here: follow_redirects defaults to False and this call site doesn't override it, so the transport never sees a second host. That's also why this doesn't need the per-hop revalidation the web fetch path does.

Two helpers in tests/test_integrations_api_call_truncation.py were stubbing check_outbound_url open, which skips the resolution the pin reads from. They now point _default_resolver at a public address instead, so the real guard runs and those tests still test the truncation and URL-joining behaviour they were written for.

Target branch

  • This PR targets dev, not main.

Linked Issue

Fixes #6

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app (docker compose up or uvicorn app:app) and verified the change works end-to-end. Type-checks and unit tests are not enough.

How to Test

python -m pytest tests/test_integration_api_call_ssrf.py \
                 tests/test_integrations_api_call_truncation.py \
                 tests/test_integrations_url_join.py

30 pass. Nine of them fail on dev: the pin assertions, the fallback ones, the CA-source one and the literal/zone-id ones. The four original guard tests pass before and after, so nothing that already existed changed behaviour.

Some of the coverage is there specifically because I didn't trust the fake-transport tests on their own:

  • test_real_socket_falls_back_from_dead_first_to_live_second uses actual sockets, a dead first address and a live asyncio server second, and checks the Host header survives the fallback.
  • test_connect_raises_when_every_validated_address_is_dead pins the all-dead path so the fallback loop can't silently swallow failures.
  • test_ip_literal_base_url_still_pins_and_is_not_rejected uses the real resolver, no monkeypatch, to confirm a bare-IP base_url doesn't trip the new "did not resolve" branch. getaddrinfo hands a literal straight back, so the captured list is populated and the pin is a no-op rather than a rejection. This one exists because that branch was the most likely way for me to have broken something.
  • test_validated_ips_strips_zone_id_and_drops_junk covers scoped v6 like fe80::1%eth0.

To watch the actual rebind instead of a unit test: run two local HTTP servers on 127.0.0.1 and 127.0.0.2, point an integration's base_url at a hostname, have the guard's resolver return 127.0.0.1, and make the name answer 127.0.0.2 by connect time. On dev the request lands on the second server with the Authorization header attached. On this branch it stays on the first. I ran it both ways.

httpx 0.28.1 / httpcore 1.0.9.

One CI note: pytest can't go green here until the Any import fix lands, src/agent_loop.py can't be imported and it takes tests/test_api_call_integration_routing.py down with it during collection. Unrelated to this.

execute_api_call validates the joined URL with check_outbound_url, which
resolves the host and classifies every address, but it only returns
(ok, reason) — no IP. The request then goes out on a plain httpx.AsyncClient,
which resolves the host again when it opens the socket. Between those two
resolutions the answer can change, so a low-TTL host that returns a public
address for the guard and 169.254.169.254 for the connect reaches cloud
metadata with the integration's stored auth headers attached. The agent can
drive this through the api_call tool.

Every other outbound path in the tree already pins against exactly this: web
fetch has _PinnedTransport in services/search/content.py and webhook delivery
has _PinnedAsyncTransport in src/webhook_manager.py. api_call had the
validation half but not the pin.

Wrap the resolver so the guard's own resolution is captured, then send the
request through a local pinned transport bound to those addresses. Connect
falls back address-by-address across the approved set on a dead first address,
sharing one deadline so N dead addresses cannot stretch the connect phase to
N * timeout, and never re-resolves. Only the socket destination is pinned —
SNI and the Host header stay the original hostname. The transport builds its
SSL context with httpx.create_ssl_context() so CA trust matches what the
default client would have used.

The two helpers in test_integrations_api_call_truncation.py stubbed
check_outbound_url open, which skipped the resolution the pin reads; they now
point the resolver at a public address so the real guard still runs.
@ashvinctrl

Copy link
Copy Markdown
Author

Closing - opened against the snapshot mirror by mistake. The api_call SSRF pin is tracked on the main repo as odysseus-dev/odysseus#5727.

@ashvinctrl ashvinctrl closed this Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integration api_call resolves the host twice, so the SSRF guard's answer isn't the one it connects to

1 participant