ForcePilot/backend/package/yuxi/channels/adapters/synologychat/webhook_handler.py

225 lines
7.7 KiB
Python
Raw Normal View History

"""Webhook receiver for Synology Chat Outgoing Webhook integration.
Provides an optional HTTP endpoint for receiving messages via Synology Chat's
Outgoing Webhook mechanism as an alternative to the polling-based approach.
Supports token validation with constant-time comparison, multi-source token
extraction, content-type fallback, request size limits, and pre-auth
IP-based rate limiting for invalid token attempts.
"""
from __future__ import annotations
import hmac
import json
import time
from collections import defaultdict
from typing import Any
from urllib.parse import parse_qs
from yuxi.utils.logging_config import logger
_WEBHOOK_TOKEN_HEADER = "X-Synology-Chat-Token"
_WEBHOOK_TOKEN_ALT_HEADERS = [
"x-webhook-token",
"x-openclaw-token",
]
_WEBHOOK_MAX_BODY_BYTES = 64 * 1024
_WEBHOOK_READ_TIMEOUT_SECONDS = 5.0
_INVALID_TOKEN_MAX_ATTEMPTS = 5
_INVALID_TOKEN_WINDOW_SECONDS = 60
_INVALID_TOKEN_BLOCK_SECONDS = 300
def verify_token(received_token: str, expected_token: str) -> bool:
"""Constant-time token comparison to prevent timing attacks."""
if not received_token or not expected_token:
return False
return hmac.compare_digest(received_token.encode(), expected_token.encode())
class InvalidTokenRateLimiter:
"""Pre-auth IP-based rate limiter for invalid webhook token attempts.
Tracks failed token verification per source IP. After exceeding the
allowed attempts within the window, the IP is blocked for a cooldown
period.
"""
def __init__(
self,
max_attempts: int = _INVALID_TOKEN_MAX_ATTEMPTS,
window_seconds: int = _INVALID_TOKEN_WINDOW_SECONDS,
block_seconds: int = _INVALID_TOKEN_BLOCK_SECONDS,
):
self._max_attempts = max_attempts
self._window_seconds = window_seconds
self._block_seconds = block_seconds
self._attempts: dict[str, list[float]] = defaultdict(list)
self._blocked_until: dict[str, float] = {}
def allow(self, source_ip: str) -> bool:
now = time.monotonic()
blocked_until = self._blocked_until.get(source_ip)
if blocked_until is not None:
if now < blocked_until:
return False
del self._blocked_until[source_ip]
timestamps = self._attempts[source_ip]
cutoff = now - self._window_seconds
recent = [t for t in timestamps if t > cutoff]
self._attempts[source_ip] = recent
if len(recent) > self._max_attempts:
self._blocked_until[source_ip] = now + self._block_seconds
logger.warning(
f"[SynologyChat] IP {source_ip} blocked for {self._block_seconds}s "
f"after {len(recent)} invalid token attempts"
)
return False
return True
def record_failure(self, source_ip: str) -> None:
self._attempts[source_ip].append(time.monotonic())
def clear(self, source_ip: str | None = None) -> None:
if source_ip:
self._attempts.pop(source_ip, None)
self._blocked_until.pop(source_ip, None)
else:
self._attempts.clear()
self._blocked_until.clear()
_invalid_token_limiter = InvalidTokenRateLimiter()
def clear_invalid_token_limiter_for_test() -> None:
_invalid_token_limiter.clear()
def extract_token_from_request(
headers: dict[str, str],
query: dict[str, str],
body: dict[str, Any] | None = None,
) -> str | None:
token = headers.get(_WEBHOOK_TOKEN_HEADER)
if token:
return token
for header_name in _WEBHOOK_TOKEN_ALT_HEADERS:
token = headers.get(header_name)
if token:
return token
auth_header = headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header[7:].strip()
token = query.get("token")
if token:
return token
if body and isinstance(body, dict):
token = body.get("token")
if token:
return token
return None
def parse_webhook_payload(body: dict[str, Any]) -> dict[str, Any] | None:
try:
return {
"user_id": str(body.get("user_id", "")),
"username": body.get("username", ""),
"message_id": str(body.get("message_id", "")),
"channel_id": str(body.get("channel_id", "")),
"channel_name": body.get("channel_name", ""),
"text": body.get("text", ""),
"event_type": body.get("event_type", "message"),
"timestamp": body.get("timestamp", 0),
}
except Exception as e:
logger.error(f"[SynologyChat] Webhook payload parse error: {e}")
return None
async def handle_webhook_event(
payload: dict[str, Any],
token: str | None,
expected_token: str | None,
source_ip: str = "unknown",
) -> dict[str, Any]:
if expected_token:
if not _invalid_token_limiter.allow(source_ip):
logger.warning(f"[SynologyChat] IP {source_ip} rate-limited for invalid tokens")
return {"status": "rate_limited", "message": "Too many invalid token attempts"}
if not token:
_invalid_token_limiter.record_failure(source_ip)
logger.warning("[SynologyChat] Webhook request missing token")
return {"status": "unauthorized", "message": "Missing token"}
if not verify_token(token, expected_token):
_invalid_token_limiter.record_failure(source_ip)
logger.warning("[SynologyChat] Webhook token verification failed")
return {"status": "unauthorized", "message": "Invalid token"}
parsed = parse_webhook_payload(payload)
if not parsed:
return {"status": "error", "message": "Failed to parse webhook payload"}
logger.info(f"[SynologyChat] Webhook received: user={parsed['user_id']}, type={parsed['event_type']}")
return {"status": "ok", "payload": parsed}
def parse_body_with_fallback(
raw_body: bytes,
content_type: str = "",
max_bytes: int = _WEBHOOK_MAX_BODY_BYTES,
) -> dict[str, Any] | None:
"""Parse webhook request body with Content-Type auto-detection and size limit.
Attempts parsing in order: application/json application/x-www-form-urlencoded
plain text as JSON form-encoded detection.
"""
if len(raw_body) > max_bytes:
logger.warning(f"[SynologyChat] Webhook body size {len(raw_body)} exceeds limit {max_bytes}")
return None
if not raw_body:
return {}
ct = content_type.lower().split(";")[0].strip()
if ct == "application/json":
try:
return json.loads(raw_body)
except json.JSONDecodeError:
logger.warning("[SynologyChat] Webhook body is not valid JSON despite content-type")
return None
if ct == "application/x-www-form-urlencoded":
try:
text = raw_body.decode("utf-8")
parsed = parse_qs(text, keep_blank_values=True)
return {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
except Exception as e:
logger.warning(f"[SynologyChat] Form-encoded body parse error: {e}")
return None
if not ct or ct == "text/plain":
try:
return json.loads(raw_body)
except json.JSONDecodeError:
try:
text = raw_body.decode("utf-8")
parsed = parse_qs(text, keep_blank_values=True)
if parsed:
return {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
except Exception as e:
logger.warning(f"[SynologyChat] Body auto-detect parse error: {e}")
return None
def validate_body_size(raw_body: bytes, max_bytes: int = _WEBHOOK_MAX_BODY_BYTES) -> bool:
"""Check request body is within size limits."""
return len(raw_body) <= max_bytes