fix(webhook): widen INSECURE_NO_AUTH loopback check + tests + docs

Follow-up to the previous commit:
- Add _is_loopback_host() helper covering 127.0.0.1, localhost, ::1,
  ip6-localhost, ip6-loopback (case-insensitive). Empty/None host is
  treated as non-loopback since unset usually means public default bind.
- Fix mixed-indent comment in the safety rail (comment now aligned with
  the if-block) and collapse the nested-if into one condition.
- Add TestInsecureNoAuthSafetyRail covering rejection on 0.0.0.0, a LAN
  IP, and empty host; allowance on 127.0.0.1/localhost; plus unit-level
  parametrized coverage of _is_loopback_host for spellings we can't bind
  in the hermetic test env (::1, ip6-localhost, ip6-loopback).
- Pin test_connect_starts_server + test_webhook_deliver_only defaults
  to 127.0.0.1 so they keep passing under the new rail.
- Document the behavior in website/docs/user-guide/messaging/webhooks.md.
This commit is contained in:
teknium1 2026-05-07 07:34:54 -07:00 committed by Teknium
parent fb4f953569
commit 898b6d7d55
4 changed files with 116 additions and 11 deletions

View file

@ -59,6 +59,29 @@ DEFAULT_PORT = 8644
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
# Hostnames/IP literals that only serve connections originating on the same
# machine. Anything else is treated as a public bind for safety-rail purposes.
_LOOPBACK_HOSTS = frozenset({
"127.0.0.1",
"localhost",
"::1",
"ip6-localhost",
"ip6-loopback",
})
def _is_loopback_host(host: str) -> bool:
"""True when `host` binds only to the local machine.
Covers IPv4 loopback, the standard `localhost` alias, IPv6 loopback in
both bracketed and bare form, and the common Debian-style aliases. Any
falsy value (empty string, None) is conservatively treated as non-loopback
because an unset host usually means the platform-default public bind.
"""
if not host:
return False
return host.strip().lower() in _LOOPBACK_HOSTS
def check_webhook_requirements() -> bool:
"""Check if webhook adapter dependencies are available."""
@ -125,15 +148,18 @@ class WebhookAdapter(BasePlatformAdapter):
f"Set 'secret' on the route or globally. "
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
)
# Safety rail: Prevent INSECURE_NO_AUTH on non-localhost bindings
if secret == _INSECURE_NO_AUTH:
if self._host not in ("127.0.0.1", "localhost"):
raise ValueError(
f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
f"but is bound to non-localhost host '{self._host}'. "
f"INSECURE_NO_AUTH is for local testing only. "
f"Refusing to start to prevent accidental exposure."
)
# Safety rail: refuse to start if INSECURE_NO_AUTH is combined with a
# non-loopback bind. The escape hatch is for local testing only;
# serving an unauthenticated route on a public interface is a
# deployment-grade footgun we'd rather crash early than ship.
if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
raise ValueError(
f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
f"but is bound to non-loopback host '{self._host}'. "
f"INSECURE_NO_AUTH is for local testing only. "
f"Refusing to start to prevent accidental exposure."
)
# deliver_only routes bypass the agent — the POST body becomes a
# direct push notification via the configured delivery target.
# Validate up-front so misconfiguration surfaces at startup rather