fix(security): validate integration api_call URLs with the outbound SSRF guard (#5145)

execute_api_call — reachable by the LLM through the api_call agent
tool — joined the integration's user-configured base_url with an
LLM-controlled path and requested it with no IP validation, so a
base_url (or a hostname resolving) into the metadata range
(169.254.169.254) was fetched server-side with the integration's auth
headers attached.

Run check_outbound_url on the joined URL before connecting, matching
the gallery endpoint, embeddings, CardDAV, and reminder webhook
surfaces. Link-local/metadata is always rejected;
INTEGRATION_API_BLOCK_PRIVATE_IPS=true also blocks RFC-1918/loopback.
Private stays allowed by default because LAN integrations
(Home Assistant, Miniflux, ntfy) are the primary use case.

The truncation-test helpers stub the guard open because their
api.example.com fixture host does not resolve and the guard fails
closed on DNS errors.

Fixes #5143

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wes Huber
2026-07-04 08:58:14 -07:00
committed by GitHub
parent d3faa00aaa
commit 3dd031c139
3 changed files with 121 additions and 0 deletions
+16
View File
@@ -394,6 +394,22 @@ async def execute_api_call(
return {"error": "Path must not contain a fragment", "exit_code": 1}
url = _join_integration_url(base_url, path)
# SSRF guard — same check used by the gallery endpoint, embeddings,
# CardDAV, and the reminder webhook sender. Link-local / metadata
# addresses (169.254.x.x — the cloud credential-exfil vector) are always
# rejected; INTEGRATION_API_BLOCK_PRIVATE_IPS=true also blocks RFC-1918 /
# loopback for locked-down deployments. Private stays allowed by default
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
# primary use case.
from src.url_safety import check_outbound_url
block_private = os.getenv(
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
).lower() == "true"
ok, reason = check_outbound_url(url, block_private=block_private)
if not ok:
return {"error": f"URL rejected: {reason}", "exit_code": 1}
method = method.upper()
# Build headers