mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-26 18:12:21 +02:00
fix(integrations): pin api_call to the SSRF-validated IP (#5727)
* fix(integrations): pin api_call to the SSRF-validated IP execute_api_call runs check_outbound_url on the target, but that guard only resolves the host to answer (ok, reason) and hands back no address. The request right after it opened a plain httpx.AsyncClient, which resolves the host again at connect time. A base_url host on a low TTL can pass the guard as a public IP and then flip to 169.254.169.254 for the connect, so the call lands on cloud metadata with the integration's stored auth headers attached. Resolve once, remember the IPs the guard actually validated, and pin the client's socket to that set through a small AnyIO-backed transport. SNI and the Host header still come from the URL, so TLS and vhost routing are unchanged; connect-time fallback stays inside the approved address set over one shared deadline. This is the same pinning the webhook sender and web-fetch paths already do -- api_call was the last outbound path that skipped it. Fixes #5513 * fix(integrations): de-duplicate the pinned IP list _default_resolver calls getaddrinfo(host, None) with no socktype filter, so glibc returns one record per socktype and a single-homed host comes back three times over. _validated_ips kept every entry, so the transport pinned the same address repeatedly and the connect fallback could spend its shared deadline retrying one dead address instead of moving on to a genuinely different one. Windows getaddrinfo collapses those duplicate records, which is why the ip-literal pin test only failed on CI and not locally.
This commit is contained in:
@@ -83,9 +83,10 @@ async def _call(json_data, status=200):
|
||||
with (
|
||||
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
|
||||
patch("httpx.AsyncClient", return_value=mock_client),
|
||||
# api.example.com doesn't resolve; the SSRF guard would fail closed.
|
||||
# These tests are about truncation, so stub the guard open.
|
||||
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
|
||||
# api.example.com doesn't resolve. Point the resolver at a public
|
||||
# address instead of stubbing the guard open, so the real check (and
|
||||
# the connect-IP pinning that reads its result) still runs.
|
||||
patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
|
||||
):
|
||||
return await integrations.execute_api_call("test_integ", "GET", "/items")
|
||||
|
||||
@@ -101,9 +102,10 @@ async def _call_with_integration(integration, path="/items"):
|
||||
with (
|
||||
patch.object(integrations, "_find_integration", return_value=integration),
|
||||
patch("httpx.AsyncClient", return_value=mock_client),
|
||||
# api.example.com doesn't resolve; the SSRF guard would fail closed.
|
||||
# These tests are about URL joining, so stub the guard open.
|
||||
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
|
||||
# api.example.com doesn't resolve. Point the resolver at a public
|
||||
# address instead of stubbing the guard open, so the real check (and
|
||||
# the connect-IP pinning that reads its result) still runs.
|
||||
patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
|
||||
):
|
||||
result = await integrations.execute_api_call("test_integ", "GET", path)
|
||||
return result, mock_client
|
||||
|
||||
Reference in New Issue
Block a user