test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
"""Adversarial test for the network-isolation fixture in conftest.py.
|
|
|
|
|
|
|
|
|
|
The autouse module-level monkey-patch in tests/conftest.py wraps
|
|
|
|
|
socket.create_connection so that any non-loopback / non-RFC1918 / non-link-local
|
|
|
|
|
destination raises OSError. This file proves:
|
|
|
|
|
|
|
|
|
|
1. The block actually fires for outbound to a real public IP.
|
|
|
|
|
2. Loopback / RFC1918 / link-local / reserved-TLD destinations pass through.
|
|
|
|
|
3. The `allow_outbound_network` fixture re-enables real network for tests
|
|
|
|
|
that legitimately need it.
|
|
|
|
|
|
|
|
|
|
Without this enforcement, a test that accidentally calls real outbound
|
|
|
|
|
(forgotten mock, leaked credential triggering an SDK initialisation, new
|
|
|
|
|
code path bypassing an existing mock) can leak production credentials,
|
|
|
|
|
slow the test suite into 10-minute waits on TLS handshakes, and produce
|
|
|
|
|
flaky failures depending on whether the destination is reachable.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import socket
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_outbound_to_public_ipv4_is_blocked():
|
|
|
|
|
"""Attempting to connect to a public IP must raise OSError."""
|
|
|
|
|
with pytest.raises(OSError, match="hermes test network isolation"):
|
|
|
|
|
# 8.8.8.8 (Google DNS) is a stable real public IPv4.
|
|
|
|
|
# If we accidentally connect, the test goes to 53/tcp which is
|
|
|
|
|
# genuinely listening — so the block is what stops us, not lack of
|
|
|
|
|
# destination.
|
|
|
|
|
socket.create_connection(("8.8.8.8", 53), timeout=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_outbound_to_anthropic_ipv6_is_blocked():
|
|
|
|
|
"""The exact destination we observed leaking from earlier pytest runs."""
|
|
|
|
|
with pytest.raises(OSError, match="hermes test network isolation"):
|
|
|
|
|
socket.create_connection(("2607:6bc0::10", 443), timeout=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_outbound_to_amazon_is_blocked():
|
|
|
|
|
"""AWS endpoints (botocore / bedrock) must not reach the real service."""
|
|
|
|
|
with pytest.raises(OSError, match="hermes test network isolation"):
|
|
|
|
|
socket.create_connection(("3.173.21.63", 443), timeout=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_loopback_v4_is_allowed():
|
|
|
|
|
"""127.0.0.1 must continue to work — test_server fixture depends on it."""
|
|
|
|
|
# Listen on a temporary port + connect via the wrapped create_connection.
|
|
|
|
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
listener.bind(("127.0.0.1", 0))
|
|
|
|
|
port = listener.getsockname()[1]
|
|
|
|
|
listener.listen(1)
|
|
|
|
|
try:
|
|
|
|
|
client = socket.create_connection(("127.0.0.1", port), timeout=1)
|
|
|
|
|
client.close()
|
|
|
|
|
finally:
|
|
|
|
|
listener.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rfc1918_private_ipv4_is_allowed():
|
|
|
|
|
"""RFC1918 (10/8, 172.16/12, 192.168/16) must pass — devs run LM Studio
|
|
|
|
|
on their LAN. The block only refuses non-RFC1918 + non-loopback."""
|
|
|
|
|
import tests.conftest as _conftest
|
|
|
|
|
# Direct unit test on the predicate so we don't have to start a real listener
|
|
|
|
|
# in a private-IP subnet just to prove this.
|
|
|
|
|
assert _conftest._hermes_addr_is_local("10.0.0.5") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("172.16.5.1") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("172.31.255.254") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("192.168.1.22") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_link_local_is_allowed():
|
|
|
|
|
"""169.254.0.0/16 (link-local / IMDS) — AWS_EC2_METADATA_DISABLED already
|
|
|
|
|
short-circuits the actual probe but the socket layer allows it."""
|
|
|
|
|
import tests.conftest as _conftest
|
|
|
|
|
assert _conftest._hermes_addr_is_local("169.254.169.254") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reserved_tlds_are_allowed():
|
|
|
|
|
"""RFC 2606/6761 reserved TLDs — used as documentation hostnames in tests
|
|
|
|
|
(e.g. example.com, test-host.invalid)."""
|
|
|
|
|
import tests.conftest as _conftest
|
|
|
|
|
assert _conftest._hermes_addr_is_local("example.com") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("my-mac.tailnet.example") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("anything.invalid") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("test-host.test") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("printer.local") is True
|
|
|
|
|
assert _conftest._hermes_addr_is_local("localhost") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_public_ipv4_is_blocked():
|
|
|
|
|
"""Public IPs must NOT be treated as local."""
|
|
|
|
|
import tests.conftest as _conftest
|
|
|
|
|
assert _conftest._hermes_addr_is_local("8.8.8.8") is False
|
|
|
|
|
assert _conftest._hermes_addr_is_local("1.1.1.1") is False
|
|
|
|
|
assert _conftest._hermes_addr_is_local("203.0.113.0") is True # TEST-NET-3
|
|
|
|
|
assert _conftest._hermes_addr_is_local("204.0.113.0") is False # outside
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 06:15:46 +00:00
|
|
|
def test_allow_outbound_network_fixture_unswaps_the_wrappers(allow_outbound_network):
|
|
|
|
|
"""When a test opts in to the fixture, socket.create_connection and
|
|
|
|
|
socket.socket.connect are restored to their real (unwrapped) implementations
|
|
|
|
|
for this test only.
|
|
|
|
|
|
2026-05-11 06:18:13 +00:00
|
|
|
Check by qname so this is robust against pytest re-importing conftest
|
|
|
|
|
under multiple roots (which produces two distinct function objects with
|
|
|
|
|
the same __qualname__ but different `is` identity).
|
2026-05-11 06:12:07 +00:00
|
|
|
"""
|
2026-05-11 06:18:13 +00:00
|
|
|
# Inside the fixture, the symbol should NOT be the blocked wrapper.
|
|
|
|
|
assert "_hermes_blocked_create_connection" not in getattr(
|
|
|
|
|
socket.create_connection, "__qualname__", ""
|
|
|
|
|
), "allow_outbound_network fixture did not restore the real create_connection"
|
|
|
|
|
assert "_hermes_blocked_socket_connect" not in getattr(
|
|
|
|
|
socket.socket.connect, "__qualname__", ""
|
|
|
|
|
), "allow_outbound_network fixture did not restore the real socket.connect"
|
2026-05-11 06:12:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_block_is_active_outside_the_fixture():
|
2026-05-11 06:15:46 +00:00
|
|
|
"""Sanity: a test that does NOT request the fixture has the wrapped
|
2026-05-11 06:18:13 +00:00
|
|
|
socket.create_connection installed.
|
|
|
|
|
|
|
|
|
|
Check by qname so this is robust against pytest re-importing conftest
|
|
|
|
|
under multiple roots (which produces two distinct function objects with
|
|
|
|
|
the same __qualname__ but different `is` identity)."""
|
|
|
|
|
assert "_hermes_blocked_create_connection" in getattr(
|
|
|
|
|
socket.create_connection, "__qualname__", ""
|
|
|
|
|
), "default state should have the blocked wrapper installed on socket.create_connection"
|
|
|
|
|
assert "_hermes_blocked_socket_connect" in getattr(
|
|
|
|
|
socket.socket.connect, "__qualname__", ""
|
|
|
|
|
), "default state should have the blocked wrapper installed on socket.socket.connect"
|