From 6ad97e043ac4b81b151cba400a204acfa4bffadf Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 7 Sep 2026 16:11:05 +0200 Subject: [PATCH 1/4] Decode hostname to string if passed as bytes --- aikido_zen/sinks/socket/__init__.py | 6 ++++++ aikido_zen/sinks/tests/socket_test.py | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/aikido_zen/sinks/socket/__init__.py b/aikido_zen/sinks/socket/__init__.py index 574f2483d..9d8a4530d 100644 --- a/aikido_zen/sinks/socket/__init__.py +++ b/aikido_zen/sinks/socket/__init__.py @@ -18,6 +18,12 @@ def _getaddrinfo_after(func, instance, args, kwargs, return_value): host = get_argument(args, kwargs, 0, "host") port = get_argument(args, kwargs, 1, "port") + if isinstance(host, bytes): + try: + host = host.decode("ascii") + except UnicodeDecodeError: + return + # We want a normalized hostname for reporting & blocking outbound domains # This function decodes the hostname if its written in punycode hostname = normalize_hostname(host) diff --git a/aikido_zen/sinks/tests/socket_test.py b/aikido_zen/sinks/tests/socket_test.py index 6e8bfa45e..113f22b2f 100644 --- a/aikido_zen/sinks/tests/socket_test.py +++ b/aikido_zen/sinks/tests/socket_test.py @@ -20,14 +20,15 @@ def run_around_tests(): current_context.set(None) -def test_socket_getaddrinfo_no_blocking(): +@pytest.mark.parametrize("host", ["localhost", b"localhost"], ids=["str", "bytes"]) +def test_socket_getaddrinfo_no_blocking(host): """Test that getaddrinfo works normally when no blocking is configured""" # Reset cache to ensure clean state get_cache().reset() # Test that allowed domain doesn't throw an error try: - socket.getaddrinfo("localhost", 80) + socket.getaddrinfo(host, 80) except Exception: pytest.fail("getaddrinfo should not throw an error for allowed domains") From 9dbd3628aace33889e74fbe07e27b99868643571 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 7 Sep 2026 18:14:04 +0200 Subject: [PATCH 2/4] Decode to utf-8 to support internationalized hostnames --- aikido_zen/sinks/socket/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/aikido_zen/sinks/socket/__init__.py b/aikido_zen/sinks/socket/__init__.py index 9d8a4530d..64b1244d9 100644 --- a/aikido_zen/sinks/socket/__init__.py +++ b/aikido_zen/sinks/socket/__init__.py @@ -19,10 +19,7 @@ def _getaddrinfo_after(func, instance, args, kwargs, return_value): port = get_argument(args, kwargs, 1, "port") if isinstance(host, bytes): - try: - host = host.decode("ascii") - except UnicodeDecodeError: - return + host = host.decode("utf-8", errors="replace") # We want a normalized hostname for reporting & blocking outbound domains # This function decodes the hostname if its written in punycode From b1cbe72102fca7f8da4ca4a6eb2f2e1a92bcd175 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 7 Sep 2026 18:14:44 +0200 Subject: [PATCH 3/4] Add test for httpx that sends hostname as bytes --- aikido_zen/sinks/tests/socket_test.py | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/aikido_zen/sinks/tests/socket_test.py b/aikido_zen/sinks/tests/socket_test.py index 113f22b2f..4968f7f4b 100644 --- a/aikido_zen/sinks/tests/socket_test.py +++ b/aikido_zen/sinks/tests/socket_test.py @@ -2,8 +2,10 @@ Test module for socket sink """ +import asyncio import socket import pytest +import httpx from unittest.mock import patch, MagicMock import aikido_zen.sinks.socket # Import to ensure patching from aikido_zen.context import current_context @@ -40,6 +42,34 @@ def test_socket_getaddrinfo_no_blocking(host): assert hostnames[0]["hits"] == 1 +@pytest.mark.asyncio +async def test_httpx_async_client_tracks_hostname_as_string(): + async def handle_request(reader, writer): + await reader.readuntil(b"\r\n\r\n") + writer.write( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + await writer.drain() + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle_request, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + get_cache().reset() + + try: + async with httpx.AsyncClient(trust_env=False) as client: + response = await client.get(f"http://localhost:{port}") + assert response.status_code == 204 + finally: + server.close() + await server.wait_closed() + + assert get_cache().hostnames.as_array() == [ + {"hostname": "localhost", "port": port, "hits": 1} + ] + + def test_socket_getaddrinfo_block_specific_domain(): """Test that getaddrinfo raises exception when specific domain is blocked""" # Reset cache and set up blocking for specific domain From ad360ae8efa7df64362e0c8bf2c6f904bb6dfff6 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 7 Sep 2026 18:16:51 +0200 Subject: [PATCH 4/4] Add test case for international hostname --- aikido_zen/sinks/tests/socket_test.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/aikido_zen/sinks/tests/socket_test.py b/aikido_zen/sinks/tests/socket_test.py index 4968f7f4b..352f1b289 100644 --- a/aikido_zen/sinks/tests/socket_test.py +++ b/aikido_zen/sinks/tests/socket_test.py @@ -22,8 +22,19 @@ def run_around_tests(): current_context.set(None) -@pytest.mark.parametrize("host", ["localhost", b"localhost"], ids=["str", "bytes"]) -def test_socket_getaddrinfo_no_blocking(host): +@pytest.mark.parametrize( + ("host", "expected_hostname"), + [ + pytest.param("localhost", "localhost", id="str"), + pytest.param(b"localhost", "localhost", id="bytes"), + pytest.param( + b"xn--ssrf-rdirects-ghb.testssandbox.com", + "ssrf-rédirects.testssandbox.com", + id="idn-bytes", + ), + ], +) +def test_socket_getaddrinfo_no_blocking(host, expected_hostname): """Test that getaddrinfo works normally when no blocking is configured""" # Reset cache to ensure clean state get_cache().reset() @@ -37,7 +48,7 @@ def test_socket_getaddrinfo_no_blocking(host): # Verify hostname was tracked hostnames = get_cache().hostnames.as_array() assert len(hostnames) == 1 - assert hostnames[0]["hostname"] == "localhost" + assert hostnames[0]["hostname"] == expected_hostname assert hostnames[0]["port"] == 80 assert hostnames[0]["hits"] == 1