From 87a2dc4badc4e6b6773995cb7c2ed2739bc1b0fd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:44:26 +0000 Subject: [PATCH 1/2] fix: classify transient DNS failures in SSRF filter as transient errors Co-Authored-By: bot_apk --- airbyte_cdk/entrypoint.py | 10 +++++ unit_tests/test_entrypoint.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 57820f0053..64765f12aa 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -430,6 +430,16 @@ def filtered_send(self: Any, request: PreparedRequest, **kwargs: Any) -> Respons message="Invalid URL endpoint: The endpoint that data is being requested from belongs to a private network. Source connectors only support requesting data from public API endpoints.", ) except socket.gaierror as exception: + error_code = exception.errno + if error_code is None and exception.args: + error_code = exception.args[0] + if error_code == getattr(socket, "EAI_AGAIN", -3): + raise AirbyteTracedException( + internal_message=f"DNS resolution failed for hostname {parsed_url.hostname!r}: {exception}", + failure_type=FailureType.transient_error, + message=f"DNS resolution temporarily failed for hostname {parsed_url.hostname!r}.", + exception=exception, + ) # This is a special case where the developer specifies an IP address string that is not formatted correctly like trailing # whitespace which will fail the socket IP lookup. This only happens when using IP addresses and not text hostnames. # Knowing that this is a request using the requests library, we will mock the exception without calling the lib diff --git a/unit_tests/test_entrypoint.py b/unit_tests/test_entrypoint.py index fcfb449151..0000589820 100644 --- a/unit_tests/test_entrypoint.py +++ b/unit_tests/test_entrypoint.py @@ -3,6 +3,7 @@ # import os +import socket from argparse import Namespace from collections import defaultdict from copy import deepcopy @@ -117,6 +118,80 @@ def test_airbyte_entrypoint_init(mocker): ) +@pytest.fixture +def internal_request_filter(): + original_send = requests.Session.send + send_mock = MagicMock() + requests.Session.send = send_mock + entrypoint_module._init_internal_request_filter() + yield send_mock + requests.Session.send = original_send + + +def test_internal_request_filter_transient_dns_failure(internal_request_filter, mocker): + hostname = "graph.facebook.com" + token = "secret-token" + request = requests.Request("GET", f"https://{hostname}/endpoint?access_token={token}").prepare() + mocker.patch.object( + socket, + "getaddrinfo", + side_effect=socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution"), + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + requests.Session().send(request) + + exception = exc_info.value + assert exception.failure_type == FailureType.transient_error + assert hostname in exception.message + assert "DNS resolution temporarily failed" in exception.message + assert token not in (exception.message or "") + assert token not in (exception.internal_message or "") + + +def test_internal_request_filter_non_transient_dns_failure(internal_request_filter, mocker): + hostname = "graph.facebook.com" + request = requests.Request( + "GET", f"https://{hostname}/endpoint?access_token=secret-token" + ).prepare() + mocker.patch.object( + socket, + "getaddrinfo", + side_effect=socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + ) + + with pytest.raises(requests.exceptions.InvalidURL): + requests.Session().send(request) + + +def test_internal_request_filter_private_ip(internal_request_filter, mocker): + request = requests.Request("GET", "https://127.0.0.1/endpoint").prepare() + mocker.patch.object( + socket, + "getaddrinfo", + return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443))], + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + requests.Session().send(request) + + assert exc_info.value.failure_type == FailureType.config_error + + +def test_internal_request_filter_public_ip_passes_through(internal_request_filter, mocker): + request = requests.Request("GET", "https://graph.facebook.com/endpoint").prepare() + mocker.patch.object( + socket, + "getaddrinfo", + return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("157.240.241.17", 443))], + ) + + requests.Session().send(request) + + assert internal_request_filter.call_count == 1 + assert internal_request_filter.call_args.args[1] == request + + @pytest.mark.parametrize( ["cmd", "args", "expected_args"], [ From 7930e75d8bbd0921783674b0e7b281148d571e9f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:53:28 +0000 Subject: [PATCH 2/2] fix: classify DNS failures for previously resolved hosts Co-Authored-By: bot_apk --- airbyte_cdk/entrypoint.py | 7 +++---- unit_tests/test_entrypoint.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 64765f12aa..645c111172 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -49,6 +49,7 @@ VALID_URL_SCHEMES = ["https"] CLOUD_DEPLOYMENT_MODE = "cloud" _HAS_LOGGED_FOR_SERIALIZATION_ERROR = False +_RESOLVED_HOSTNAMES: set[str] = set() class AirbyteEntrypoint(object): @@ -430,10 +431,7 @@ def filtered_send(self: Any, request: PreparedRequest, **kwargs: Any) -> Respons message="Invalid URL endpoint: The endpoint that data is being requested from belongs to a private network. Source connectors only support requesting data from public API endpoints.", ) except socket.gaierror as exception: - error_code = exception.errno - if error_code is None and exception.args: - error_code = exception.args[0] - if error_code == getattr(socket, "EAI_AGAIN", -3): + if parsed_url.hostname in _RESOLVED_HOSTNAMES: raise AirbyteTracedException( internal_message=f"DNS resolution failed for hostname {parsed_url.hostname!r}: {exception}", failure_type=FailureType.transient_error, @@ -455,6 +453,7 @@ def _is_private_url(hostname: str, port: int) -> bool: Helper method that checks if any of the IP addresses associated with a hostname belong to a private network. """ address_info_entries = socket.getaddrinfo(hostname, port) + _RESOLVED_HOSTNAMES.add(hostname) for entry in address_info_entries: # getaddrinfo() returns entries in the form of a 5-tuple where the IP is stored as the sockaddr. For IPv4 this # is a 2-tuple and for IPv6 it is a 4-tuple, but the address is always the first value of the tuple at 0. diff --git a/unit_tests/test_entrypoint.py b/unit_tests/test_entrypoint.py index 0000589820..4262490e75 100644 --- a/unit_tests/test_entrypoint.py +++ b/unit_tests/test_entrypoint.py @@ -121,11 +121,16 @@ def test_airbyte_entrypoint_init(mocker): @pytest.fixture def internal_request_filter(): original_send = requests.Session.send + original_resolved_hostnames = entrypoint_module._RESOLVED_HOSTNAMES send_mock = MagicMock() requests.Session.send = send_mock + entrypoint_module._RESOLVED_HOSTNAMES = set() entrypoint_module._init_internal_request_filter() - yield send_mock - requests.Session.send = original_send + try: + yield send_mock + finally: + requests.Session.send = original_send + entrypoint_module._RESOLVED_HOSTNAMES = original_resolved_hostnames def test_internal_request_filter_transient_dns_failure(internal_request_filter, mocker): @@ -135,9 +140,17 @@ def test_internal_request_filter_transient_dns_failure(internal_request_filter, mocker.patch.object( socket, "getaddrinfo", - side_effect=socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution"), + side_effect=[ + [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("157.240.241.17", 443))], + socket.gaierror( + getattr(socket, "EAI_AGAIN", -3), "Temporary failure in name resolution" + ), + ], ) + requests.Session().send(request) + assert hostname in entrypoint_module._RESOLVED_HOSTNAMES + with pytest.raises(AirbyteTracedException) as exc_info: requests.Session().send(request) @@ -149,15 +162,17 @@ def test_internal_request_filter_transient_dns_failure(internal_request_filter, assert token not in (exception.internal_message or "") -def test_internal_request_filter_non_transient_dns_failure(internal_request_filter, mocker): - hostname = "graph.facebook.com" - request = requests.Request( - "GET", f"https://{hostname}/endpoint?access_token=secret-token" - ).prepare() +def test_internal_request_filter_unresolved_hostname_keeps_invalid_url( + internal_request_filter, mocker +): + hostname = "domainwithoutextension" + request = requests.Request("GET", f"https://{hostname}/endpoint").prepare() mocker.patch.object( socket, "getaddrinfo", - side_effect=socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + side_effect=socket.gaierror( + getattr(socket, "EAI_AGAIN", -3), "Temporary failure in name resolution" + ), ) with pytest.raises(requests.exceptions.InvalidURL):