225 lines
7.7 KiB
Python
225 lines
7.7 KiB
Python
|
|
"""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
|