Adapter bind address and security hardening fixes
Cluster 4260 · gateway. Data as of
1 open item. Fix merged in #65482 on 16 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 0 issues already closed.
- Fix event
- A fix was merged to main in PR #65482. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #65482, then check whether your report is the one still open.
Read #65482Pick up a Lab taskThese issues and PRs address default network bind addresses for SMS and webhook adapters (loopback vs. dual-stack vs. 0.0.0.0), along with related security vulnerability patches and socket option adjustments. The members share overlapping but not fully unified themes around network exposure and security hardening.
Open issues1
- Issue#4260Security: SMS and Webhook adapters bind to 0.0.0.0 by default
Open PRs0
Fixes on record
-
#19745 fix(sms): mark missing-config errors as non-retryable; default bind to 127.0.0.1
files:
gateway/platforms/sms.py,tests/gateway/test_sms.py -
#63711 fix(webhook): default to dual-stack bind so 6PN (IPv6) can reach the adapter
files:
gateway/platforms/webhook.py,hermes_cli/webhook.py,skills/autonomous-ai-agents/hermes-agent/references/webhooks.md,tests/gateway/test_webhook_adapter.py,tests/hermes_cli/test_webhook_cli.py,website/docs/guides/webhook-github-pr-review.mdShow the diff
-
gateway/platforms/webhook.py@@ -77,7 +77,25 @@ "qqbot", "yuanbao", } -DEFAULT_HOST = "0.0.0.0" +# Default bind host. ``None`` tells aiohttp/asyncio's ``create_server`` to bind +# BOTH address families (IPv4 + IPv6) — the portable dual-stack default. +# +# Why not "0.0.0.0" (the old default) or "::"? +# - "0.0.0.0" binds IPv4 ONLY. On IPv6-only private networks — notably Fly.io +# 6PN, where an agent's ``<app>.internal`` name resolves to an ``fdaa:…`` +# IPv6 address — an IPv4-only listener is unreachable. That is exactly why +# hosted-agent webhook routes were publicly unreachable: the edge router +# reverse-proxies to ``<app>.internal:8644`` over 6PN (IPv6) but the adapter +# was listening on 0.0.0.0 (v4 only) → connection refused. +# - "::" is NOT a safe fix: on hosts where the kernel sets IPV6_V6ONLY=1 +# (verified on Fly machines), binding "::" yields an IPv6-ONLY socket, which +# then breaks the IPv4 loopback health check (``curl 127.0.0.1:8644/health``) +# and the AF_INET port-conflict probe in connect(). +# - ``None`` asks the event loop to create a listening socket per resolved +# family, so both 127.0.0.1 (v4) and the 6PN fdaa (v6) are served regardless +# of the bindv6only sysctl. Users can still pin a specific host via +# ``platforms.webhook.extra.host``. +DEFAULT_HOST = None DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" @@ -93,7 +111,7 @@ }) -def _is_loopback_host(host: str) -> bool: +def _is_loopback_host(host: Optional[str]) -> bool: """True when `host` binds only to the local machine. Covers IPv4 loopback, the standard `localhost` alias, IPv6 loopback in @@ -116,7 +134,11 @@ class WebhookAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WEBHOOK) - self._host: str = config.extra.get("host", DEFAULT_HOST) + # ``host`` may be None (dual-stack default) or a user-pinned string. + # A config value of empty string / null is normalised to None so it + # also means "bind all families" rather than an invalid "" host. + _cfg_host = config.extra.get("host", DEFAULT_HOST) + self._host: Optional[str] = _cfg_host or None self._port: int = int(config.extra.get("port", DEFAULT_PORT)) self._global_secret: str = config.extra.get("secret", "") self._static_routes: Dict[str, dict] = config.extra.get("routes", {}) @@ -225,27 +247,39 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: "/p/{profile}/webhooks/{route_name}", self._handle_webhook ) - # Port conflict detection — fail fast if port is already in use - import socket as _socket - try: - with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: - _s.settimeout(1) - _s.connect(('127.0.0.1', self._port)) - logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port) - return False - except (ConnectionRefusedError, OSError): - pass # port is free - self._runner = web.AppRunner(app) await self._runner.setup() - site = web.TCPSite(self._runner, self._host, self._port) - await site.start() + # Do not probe only one address family before binding. With the + # dual-stack default, an IPv6-only listener can already own this port + # while 127.0.0.1 still looks free. Also disable SO_REUSEADDR for the + # listener: on macOS, two wildcard/specific sockets with SO_REUSEADDR + # can silently split traffic while both servers report success. + site = web.TCPSite( + self._runner, + self._host, + self._port, + reuse_address=False, + ) + try: + await site.start() + except OSError as exc: + await self._runner.cleanup() + self._runner = None + logger.error( + "[webhook] Could not bind %s:%d: %s. " + "Set a different host or port in config.yaml under " + "platforms.webhook.extra.", + self._host or "all IPv4+IPv6 interfaces", + self._port, + exc, + ) + return False self._mark_connected() route_names = ", ".join(self._routes.keys()) or "(none configured)" logger.info( "[webhook] Listening on %s:%d — routes: %s", - self._host, + self._host or "* (all interfaces, IPv4+IPv6)", self._port, route_names, ) -
hermes_cli/webhook.py@@ -96,9 +96,11 @@ def _is_webhook_enabled() -> bool: def _get_webhook_base_url() -> str: wh = _get_webhook_config().get("extra", {}) - host = wh.get("host", "0.0.0.0") + host = wh.get("host") port = wh.get("port", 8644) - display_host = "localhost" if host == "0.0.0.0" else host + display_host = "localhost" if not host or host in {"0.0.0.0", "::"} else host + if ":" in display_host and not display_host.startswith("["): + display_host = f"[{display_host}]" return f"http://{display_host}:{port}" @@ -115,7 +117,6 @@ def _setup_hint() -> str: webhook: enabled: true extra: - host: "0.0.0.0" port: 8644 secret: "[redacted:secret-assignment]" -
skills/autonomous-ai-agents/hermes-agent/references/webhooks.md@@ -24,11 +24,13 @@ platforms: webhook: enabled: true extra: - host: "0.0.0.0" port: 8644 secret: "[redacted:secret-assignment]" ``` +Omitting `host` uses the dual-stack default and listens on both IPv4 and IPv6. +Set a specific address only when you intentionally want to restrict the bind. + ### Option 3: Environment variables Add to `${HERMES_HOME:-~/.hermes}/.env`: ```bash -
tests/gateway/test_webhook_adapter.py@@ -19,6 +19,7 @@ import hashlib import hmac import json +import socket import time from collections import deque from unittest.mock import AsyncMock, MagicMock, patch @@ -1525,3 +1526,118 @@ async def test_connect_allows_real_secret_on_public_bind(self): assert result is True finally: await adapter.disconnect() + + +class TestDualStackBind: + """The default bind host must serve BOTH IPv4 and IPv6. + + Regression guard for the hosted-agent webhook reachability bug: Fly.io 6PN + (the private network the edge router reverse-proxies webhook traffic over) + is IPv6-only — an agent's ``<app>.internal`` name resolves to an ``fdaa:…`` + address. The adapter used to default to ``host="0.0.0.0"`` (IPv4 only), so + the router's dial to ``<app>.internal:8644`` hit an address nothing was + listening on → connection refused → public webhooks unreachable. + + The fix is ``DEFAULT_HOST = None`` (dual-stack). ``"::"`` is NOT a valid + substitute: on hosts with the ``bindv6only`` sysctl set (verified on Fly + machines) it yields an IPv6-ONLY socket, which would then break the IPv4 + loopback health check and the AF_INET port-conflict probe. + """ + + def test_default_host_is_none_for_dual_stack(self): + """The module default is None (bind all families), not 0.0.0.0/::.""" + from gateway.platforms.webhook import DEFAULT_HOST + assert DEFAULT_HOST is None + + def test_missing_host_key_resolves_to_none(self): + """Config with no host key → dual-stack (None), not a literal string.""" + cfg = PlatformConfig(enabled=True, extra={"port": 0, "routes": {}}) + adapter = WebhookAdapter(cfg) + assert adapter._host is None + + @pytest.mark.parametrize("empty", ["", None]) + def test_empty_host_normalises_to_none(self, empty): + """An explicit empty-string/null host means dual-stack, not host=''. + + Guards the old footgun where host='' was passed straight to TCPSite + AND treated as non-loopback — now it collapses to the None default. + """ + adapter = _make_adapter(host=empty, port=0) + assert adapter._host is None + + def test_pinned_host_is_preserved(self): + """A user can still pin a specific bind host via config.extra.host.""" + adapter = _make_adapter(host="127.0.0.1", port=0) + assert adapter._host == "127.0.0.1" + + @pytest.mark.asyncio + async def test_default_bind_serves_both_families(self): + """Binding the real server with the default host opens v4 AND v6 sockets. + + This is the behavioural proof: with host=None, asyncio.create_server + opens a listening socket per resolved family, so both 127.0.0.1 (v4) + and ::1 (v6) are reachable — exactly what 6PN needs. Uses a real bind + on an OS-assigned port (no mock) and inspects the runner's addresses. + """ + # Build config WITHOUT a host key so the real DEFAULT_HOST (None) + # applies — _make_adapter's helper injects host="0.0.0.0" by default, + # which would mask the dual-stack default under test here. + cfg = PlatformConfig( + enabled=True, + extra={ + "port": 0, + "routes": {"r1": {"secret": "[redacted:secret-assignment]", "prompt": "x"}}, + }, + ) + adapter = WebhookAdapter(cfg) + assert adapter._host is None + try: + with patch.object(adapter, "_reload_dynamic_routes"): + result = await adapter.connect() + assert result is True + # runner.addresses lists one bound address per listening socket. + # An IPv6 sockaddr is a 4-tuple (host, port, flowinfo, scopeid); + # an IPv4 sockaddr is a 2-tuple (host, port). With the dual-stack + # default we expect BOTH — that is precisely what makes the adapter + # reachable over 6PN (v6) AND on the loopback health check (v4). + addrs = list(adapter._runner.addresses) # type: ignore[union-attr] + has_v6 = any(len(a) == 4 for a in addrs) + has_v4 = any(len(a) == 2 for a in addrs) + assert has_v4, f"IPv4 bind missing — got {addrs}" + assert has_v6, ( + f"IPv6 bind missing (the 6PN reachability bug) — got {addrs}" + ) + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_default_bind_rejects_existing_ipv6_listener(self): + """A specific IPv6 listener must block the wildcard dual-stack bind.""" + blocker = await asyncio.start_server( + lambda _reader, _writer: None, + host="::1", + port=0, + family=socket.AF_INET6, + reuse_address=False, + ) + port = blocker.sockets[0].getsockname()[1] + cfg = PlatformConfig( + enabled=True, + extra={ + "port": port, + "routes": { + "r1": {"secret": "[redacted:secret-assignment]", "prompt": "x"} + }, + }, + ) + adapter = WebhookAdapter(cfg) + try: + with patch.object(adapter, "_reload_dynamic_routes"): + result = await adapter.connect() + assert result is False + assert adapter._runner is None + assert adapter.is_connected is False + finally: + await adapter.disconnect() + blocker.close() + await blocker.wait_closed() -
tests/hermes_cli/test_webhook_cli.py@@ -8,6 +8,7 @@ from hermes_cli.webhook import ( webhook_command, + _get_webhook_base_url, _load_subscriptions, _save_subscriptions, _subscriptions_path, @@ -41,6 +42,23 @@ def _make_args(**kwargs): return Namespace(**defaults) [redacted:email]("host", [None, "", "0.0.0.0", "::"]) +def test_webhook_base_url_maps_wildcard_hosts_to_localhost(monkeypatch, host): + monkeypatch.setattr( + "hermes_cli.webhook._get_webhook_config", + lambda: {"extra": {"host": host, "port": 9123}}, + ) + assert _get_webhook_base_url() == "http://localhost:9123" + + +def test_webhook_base_url_brackets_pinned_ipv6_host(monkeypatch): + monkeypatch.setattr( + "hermes_cli.webhook._get_webhook_config", + lambda: {"extra": {"host": "::1", "port": 9123}}, + ) + assert _get_webhook_base_url() == "http://[::1]:9123" + + class TestSubscribe: def test_basic_create(self, capsys): webhook_command(_make_args(webhook_action="subscribe", name="test-hook")) -
website/docs/guides/webhook-github-pr-review.md@@ -311,7 +311,6 @@ platforms: webhook: enabled: true extra: - host: "0.0.0.0" # bind address (default: 0.0.0.0) port: 8644 # listen port (default: 8644) secret: "" # optional global fallback secret rate_limit: 30 # requests per minute per route
-
-
#65482 fix(webhook): scope reuse_address=False to macOS only
files:
gateway/platforms/webhook.py
2 PRs closed without merging
From the close comments: merged via another change 1 · won't fix / by design 1
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab