Skip to content

fix(security): pin integration api_call to the SSRF-validated IP#5514

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

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

Conversation

@ashvinctrl

@ashvinctrl ashvinctrl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

execute_api_call (the api_call agent tool) validated its outbound URL with check_outbound_url in #5145, but then opened a plain httpx.AsyncClient() and requested the hostname URL. check_outbound_url resolves the host to decide accept/reject and returns only (ok, reason) — it hands back no IP — so httpx resolves the host a second time at connect. When those two lookups disagree (DNS rebinding, a low-TTL record that flips public → 169.254.169.254), an internal or cloud-metadata address is reached after the guard already approved a public one, with the integration's stored auth headers attached.

The repo already closes this exact TOCTOU on every other outbound path by pinning the connection to the address the guard validated: web fetch (#704 / issue #2892) and webhook delivery (#5147 / issue #5146). api_call got the validation half but not the pin, so it was the last path still exposed. api_call is agent-reachable (do_api_call in src/tools/system.py), so untrusted content driving the agent can trigger it.

The fix resolves the host once through the guard's own resolver, remembers the IPs it validated, and pins the request connection to one of them with a small async transport. The URL is passed through unchanged, so TLS SNI and the Host header stay the original hostname — only the socket destination is pinned. LAN integrations (Home Assistant, Miniflux, ntfy) keep working: private/loopback stays allowed by default and is still gated by INTEGRATION_API_BLOCK_PRIVATE_IPS.

I kept the pinned transport local to integrations.py rather than importing web fetch's or webhook's copy — that mirrors how those two modules each carry their own, and avoids coupling api_call to the webhook subsystem.

Target branch

  • This PR targets dev, not main.

Linked Issue

Fixes #5513

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

  1. pytest tests/test_integration_api_call_ssrf.py tests/test_integrations_api_call_truncation.py
  2. The new test_connection_is_pinned_to_the_validated_ip fails on dev (a plain client, no pin) and passes here; test_pin_uses_a_validated_ip_when_host_has_several covers multi-record hosts.
  3. End-to-end: point an integration base_url at a hostname that resolves to a local HTTP server, then flip that hostname's connect-time DNS answer to an unrouteable IP. The request still lands on the validated server (the pin ignores the rebind), and the Host header + bearer credential arrive unchanged — while a LAN base_url (192.168.x.x) is unaffected by default and blocked when INTEGRATION_API_BLOCK_PRIVATE_IPS=true.

No static/ changes, so no screenshot.

execute_api_call validated the outbound URL with check_outbound_url
(odysseus-dev#5145) but then opened a plain httpx client that re-resolves the host
at connect time. A base_url host that answers with a public IP for the
guard and rebinds to 169.254.169.254 for the connect slips past the
check and reaches cloud metadata with the integration's auth headers
attached — the same DNS-rebinding TOCTOU already closed for web fetch
(odysseus-dev#704) and webhook delivery (odysseus-dev#5147).

Resolve the host once through the guard's own resolver, remember the
validated IPs, and pin the request connection to one of them via a
local async transport (SNI and Host stay the original hostname). The
per-module transport mirrors the ones web fetch and webhook delivery
already carry.
@github-actions github-actions Bot added needs work PR description incomplete — please update before review ready for review Description complete — ready for maintainer review and removed needs work PR description incomplete — please update before review labels Jul 14, 2026
@VictorioZpMx

Copy link
Copy Markdown

I need help analizing un videoclip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(security): integration api_call has a DNS-rebinding TOCTOU (validation resolves, connect re-resolves)

2 participants