fix(gateway): dual-stack webhook bind for wecom/msgraph/whatsapp_cloud/teams/telegram siblings

Same class of bug as the LINE adapter (NS-603): defaulting the webhook
bind to "0.0.0.0" (or hardcoding it) binds IPv4 ONLY, so the listener
is unreachable over IPv6-only private networks such as Fly.io 6PN.

- wecom callback_adapter: DEFAULT_HOST None; config.py env seed no
  longer forces 0.0.0.0 when WECOM_CALLBACK_HOST is unset.
- msgraph_webhook: DEFAULT_HOST None; the allowed_source_cidrs
  requirement still fires for the all-interfaces default (host=None is
  treated as network-accessible).
- whatsapp_cloud: DEFAULT_WEBHOOK_HOST None.
- teams: hardcoded 0.0.0.0 TCPSite bind → _DEFAULT_HOST=None with new
  TEAMS_HOST / extra.host override (mirrors LINE_HOST pattern).
- telegram: hardcoded listen="0.0.0.0" → default "" (tornado
  bind_sockets opens one socket per address family; verified against
  PTB 22.6/tornado) with new TELEGRAM_WEBHOOK_HOST / extra.webhook_host
  override.

Explicit host overrides everywhere are preserved; empty/unset collapses
to the dual-stack default. "::" remains a bad substitute on
bindv6only=1 hosts (see LINE adapter comment).
This commit is contained in:
Teknium 2026-07-28 21:59:06 -07:00
parent cf1e3585b6
commit 2c771be406
7 changed files with 72 additions and 14 deletions

View file

@ -2323,7 +2323,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"agent_id": getenv("WECOM_CALLBACK_AGENT_ID", ""),
"token": getenv("WECOM_CALLBACK_TOKEN", ""),
"encoding_aes_key": getenv("WECOM_CALLBACK_ENCODING_AES_KEY", ""),
"host": getenv("WECOM_CALLBACK_HOST", "0.0.0.0"),
# No default here: an unset WECOM_CALLBACK_HOST leaves extra.host
# falsy so the adapter's dual-stack DEFAULT_HOST=None applies
# (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only, NS-603).
"host": getenv("WECOM_CALLBACK_HOST", ""),
"port": getenv_int("WECOM_CALLBACK_PORT", 8645),
})

View file

@ -30,7 +30,13 @@ from gateway.platforms.base import (
logger = logging.getLogger(__name__)
DEFAULT_HOST = "0.0.0.0"
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and
# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via extra.host. The all-interfaces default still
# requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing).
DEFAULT_HOST = None
DEFAULT_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
DEFAULT_MAX_SEEN_RECEIPTS = 5000
@ -49,7 +55,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
extra = config.extra or {}
self._host: str = str(extra.get("host", DEFAULT_HOST))
# Falsy host (None/"") collapses to the dual-stack default.
_raw_host = extra.get("host", DEFAULT_HOST) or DEFAULT_HOST
self._host: Optional[str] = str(_raw_host) if _raw_host else None
self._port: int = int(extra.get("port", DEFAULT_PORT))
self._webhook_path: str = self._normalize_path(
extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
@ -138,7 +146,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
self._notification_scheduler = scheduler
def _source_allowlist_required_but_missing(self) -> bool:
return is_network_accessible(self._host) and not self._allowed_source_networks
# host=None binds all interfaces (both families) — network-accessible.
host_is_public = self._host is None or is_network_accessible(self._host)
return host_is_public and not self._allowed_source_networks
async def connect(self, *, is_reconnect: bool = False) -> bool:
if self._client_state is None:

View file

@ -33,7 +33,7 @@ Optional / Phase-3+:
- WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256)
- WHATSAPP_CLOUD_WABA_ID (analytics / future use)
- WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret)
- WHATSAPP_CLOUD_WEBHOOK_HOST (default 0.0.0.0)
- WHATSAPP_CLOUD_WEBHOOK_HOST (default: unset dual-stack, all interfaces IPv4+IPv6)
- WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090)
- WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook)
- WHATSAPP_CLOUD_API_VERSION (default v20.0)
@ -86,7 +86,12 @@ logger = logging.getLogger(__name__)
DEFAULT_API_VERSION = "v20.0"
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and
# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via WHATSAPP_CLOUD_WEBHOOK_HOST or extra.webhook_host.
DEFAULT_WEBHOOK_HOST = None
DEFAULT_WEBHOOK_PORT = 8090
DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook"
GRAPH_API_BASE = "https://graph.facebook.com"
@ -217,7 +222,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
self._verify_token: str = str(extra.get("verify_token", "")).strip()
# Webhook server config
self._webhook_host: str = str(extra.get("webhook_host", DEFAULT_WEBHOOK_HOST))
# Falsy host (None/"") collapses to the dual-stack default.
_raw_webhook_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST
self._webhook_host: Optional[str] = (
str(_raw_webhook_host) if _raw_webhook_host else None
)
self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT))
self._webhook_path: str = self._normalize_path(
extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)

View file

@ -105,6 +105,12 @@ _DEFAULT_PORT = 3978
# Bot Framework activities are JSON payloads well under 1 MiB; an explicit
# aiohttp client_max_size keeps oversized/chunked request bodies bounded.
_MAX_BODY_BYTES = 1_048_576
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old hardcoded "0.0.0.0" bound IPv4 ONLY
# and was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) —
# same bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via TEAMS_HOST or extra.host.
_DEFAULT_HOST = None
_WEBHOOK_PATH = "/api/messages"
@ -705,6 +711,9 @@ class TeamsAdapter(BasePlatformAdapter):
self._port = _coerce_port(
extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))
)
# Falsy host (unset/"") collapses to the dual-stack default (None).
_raw_host = extra.get("host") or os.getenv("TEAMS_HOST", "") or _DEFAULT_HOST
self._host: Optional[str] = str(_raw_host) if _raw_host else None
self._app: Optional["App"] = None
self._runner: Optional["web.AppRunner"] = None
self._dedup = MessageDeduplicator(max_size=1000)
@ -774,13 +783,14 @@ class TeamsAdapter(BasePlatformAdapter):
self._runner = web.AppRunner(aiohttp_app)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
self._running = True
self._mark_connected()
logger.info(
"[teams] Webhook server listening on 0.0.0.0:%d%s",
"[teams] Webhook server listening on %s:%d%s",
self._host or "* (all interfaces, IPv4+IPv6)",
self._port,
_WEBHOOK_PATH,
)

View file

@ -30,6 +30,10 @@ optional_env:
description: "Webhook listen port (Bot Framework default: 3978)"
prompt: "Webhook port"
password: false
- name: TEAMS_HOST
description: "Webhook bind host (default: unset → dual-stack, all interfaces IPv4+IPv6)"
prompt: "Webhook host"
password: false
- name: TEAMS_ALLOWED_USERS
description: "Comma-separated Teams user IDs / UPNs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"

View file

@ -3592,6 +3592,8 @@ class TelegramAdapter(BasePlatformAdapter):
TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
TELEGRAM_WEBHOOK_HOST Bind host (default: unset dual-stack,
all interfaces IPv4+IPv6)
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
"""
# Explicit connect() is the only operation allowed to reopen polling
@ -3944,6 +3946,16 @@ class TelegramAdapter(BasePlatformAdapter):
# start rather than silently run in fail-open mode.
# See GHSA-3vpc-7q5r-276h.
webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443)
# Bind host. Default "" → tornado bind_sockets opens one
# listening socket per address family (IPv4 + IPv6). The old
# hardcoded "0.0.0.0" bound IPv4 ONLY and was unreachable
# over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603). Pin via
# TELEGRAM_WEBHOOK_HOST or platforms.telegram.extra.webhook_host.
webhook_host = (
os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip()
or str((self.config.extra or {}).get("webhook_host") or "").strip()
)
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip()
if not webhook_secret:
raise RuntimeError(
@ -3962,7 +3974,7 @@ class TelegramAdapter(BasePlatformAdapter):
webhook_path = urlparse(webhook_url).path or "/telegram"
await self._app.updater.start_webhook(
listen="0.0.0.0",
listen=webhook_host,
port=webhook_port,
url_path=webhook_path,
webhook_url=webhook_url,
@ -3978,8 +3990,11 @@ class TelegramAdapter(BasePlatformAdapter):
self._polling_progress_accepting = False
self._send_path_degraded = False
logger.info(
"[%s] Webhook server listening on 0.0.0.0:%d%s",
self.name, webhook_port, webhook_path,
"[%s] Webhook server listening on %s:%d%s",
self.name,
webhook_host or "* (all interfaces, IPv4+IPv6)",
webhook_port,
webhook_path,
)
else:
# ── Polling mode (default) ───────────────────────────

View file

@ -51,7 +51,12 @@ from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
logger = logging.getLogger(__name__)
DEFAULT_HOST = "0.0.0.0"
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and
# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via WECOM_CALLBACK_HOST or extra.host.
DEFAULT_HOST = None
DEFAULT_PORT = 8645
DEFAULT_PATH = "/wecom/callback"
# Cap pre-auth request bodies. WeCom callbacks are small encrypted XML
@ -71,7 +76,9 @@ class WecomCallbackAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WECOM_CALLBACK)
extra = config.extra or {}
self._host = str(extra.get("host") or DEFAULT_HOST)
# Falsy host (None/"") collapses to the dual-stack default.
_raw_host = extra.get("host") or DEFAULT_HOST
self._host = str(_raw_host) if _raw_host else None
self._port = int(extra.get("port") or DEFAULT_PORT)
self._path = str(extra.get("path") or DEFAULT_PATH)
self._apps: List[Dict[str, Any]] = self._normalize_apps(extra)