Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,20 @@ def _is_webhook_silence_response(content: Any) -> bool:
# ``platforms.webhook.extra.host``.
DEFAULT_HOST = None
DEFAULT_PORT = 8644
DEFAULT_RATE_LIMIT = 30 # requests per minute
DEFAULT_MAX_BODY_BYTES = 1_048_576 # 1MB
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
_RATE_WINDOW_SECONDS = 60.0


def _coerce_int(value: object, *, default: int) -> int:
"""Parse config ints; malformed values must not crash adapter init."""
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default

# 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({
Expand Down Expand Up @@ -190,7 +201,11 @@ def __init__(self, config: PlatformConfig):
# 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))
# Malformed port / rate / size ints must not ValueError during
# construction and take the webhook platform down (Teams/Raft posture).
self._port: int = _coerce_int(
config.extra.get("port", DEFAULT_PORT), default=DEFAULT_PORT
)
self._global_secret: str = config.extra.get("secret", "")
self._static_routes: Dict[str, dict] = config.extra.get("routes", {})
self._dynamic_routes: Dict[str, dict] = {}
Expand Down Expand Up @@ -225,17 +240,22 @@ def __init__(self, config: PlatformConfig):

# Rate limiting: per-route timestamps in a fixed window.
self._rate_counts: Dict[str, Deque[float]] = {}
self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
self._rate_limit: int = _coerce_int(
config.extra.get("rate_limit", DEFAULT_RATE_LIMIT),
default=DEFAULT_RATE_LIMIT,
)

# Body size limit (auth-before-body pattern)
self._max_body_bytes: int = int(
config.extra.get("max_body_bytes", 1_048_576)
) # 1MB
self._script_timeout_seconds: int = int(
self._max_body_bytes: int = _coerce_int(
config.extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES),
default=DEFAULT_MAX_BODY_BYTES,
)
self._script_timeout_seconds: int = _coerce_int(
config.extra.get(
"script_timeout_seconds",
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
)
),
default=DEFAULT_SCRIPT_TIMEOUT_SECONDS,
)
self._route_processor = WebhookRouteProcessor(
script_timeout_seconds=self._script_timeout_seconds
Expand Down
42 changes: 42 additions & 0 deletions tests/gateway/test_webhook_config_int_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Webhook adapter must tolerate malformed int config fields."""

from __future__ import annotations

from gateway.config import PlatformConfig
from gateway.platforms.webhook import (
DEFAULT_MAX_BODY_BYTES,
DEFAULT_PORT,
DEFAULT_RATE_LIMIT,
WebhookAdapter,
)


def _make_config(**extra):
data = {
"host": "127.0.0.1",
"port": DEFAULT_PORT,
"routes": {},
"secret": "test-secret",
}
data.update(extra)
return PlatformConfig(enabled=True, extra=data)


def test_malformed_port_falls_back_to_default():
adapter = WebhookAdapter(_make_config(port="not-a-port"))
assert adapter._port == DEFAULT_PORT


def test_malformed_rate_limit_falls_back_to_default():
adapter = WebhookAdapter(_make_config(rate_limit="nope"))
assert adapter._rate_limit == DEFAULT_RATE_LIMIT


def test_malformed_max_body_bytes_falls_back_to_default():
adapter = WebhookAdapter(_make_config(max_body_bytes="oops"))
assert adapter._max_body_bytes == DEFAULT_MAX_BODY_BYTES


def test_valid_port_is_honored():
adapter = WebhookAdapter(_make_config(port=9001))
assert adapter._port == 9001