"""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