ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/webhook_server.py

230 lines
8.5 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import logging
import time
from collections import defaultdict
from http import HTTPStatus
from typing import Any
from aiohttp import web
from .client import verify_hmac_signature
from .normalizer import normalize_inbound
from .security import validate_backend_source
logger = logging.getLogger(__name__)
DEFAULT_WEBHOOK_PORT = 8788
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"
DEFAULT_RATE_WINDOW_S = 60
DEFAULT_RATE_MAX_REQUESTS = 10
DEFAULT_RATE_LOCKOUT_S = 300
DEFAULT_MAX_BODY_SIZE = 1 * 1024 * 1024
DEFAULT_PRE_AUTH_MAX_BODY_SIZE = 64 * 1024
class _AuthRateLimiter:
def __init__(
self,
max_requests: int = DEFAULT_RATE_MAX_REQUESTS,
window_s: int = DEFAULT_RATE_WINDOW_S,
lockout_s: int = DEFAULT_RATE_LOCKOUT_S,
):
self._max_requests = max_requests
self._window_s = window_s
self._lockout_s = lockout_s
self._failures: dict[str, list[float]] = defaultdict(list)
self._lockouts: dict[str, float] = {}
def _gc(self, now: float) -> None:
stale = [ip for ip, lockout_until in self._lockouts.items() if now >= lockout_until]
for ip in stale:
del self._lockouts[ip]
cutoff = now - self._window_s
for ip in list(self._failures):
self._failures[ip] = [ts for ts in self._failures[ip] if ts > cutoff]
if not self._failures[ip]:
del self._failures[ip]
def check(self, client_ip: str) -> bool:
now = time.time()
self._gc(now)
if client_ip in self._lockouts:
if now < self._lockouts[client_ip]:
return False
del self._lockouts[client_ip]
if len(self._failures.get(client_ip, [])) >= self._max_requests:
self._lockouts[client_ip] = now + self._lockout_s
logger.warning(f"[NextcloudTalk] Auth rate limiter locked out {client_ip} for {self._lockout_s}s")
return False
return True
def record_failure(self, client_ip: str) -> None:
self._failures[client_ip].append(time.time())
def stats(self) -> dict[str, Any]:
self._gc(time.time())
return {
"tracked_ips": len(self._failures),
"locked_ips": len(self._lockouts),
}
class NextcloudTalkWebhookServer:
def __init__(
self,
adapter,
port: int = DEFAULT_WEBHOOK_PORT,
host: str = DEFAULT_WEBHOOK_HOST,
path: str = DEFAULT_WEBHOOK_PATH,
rate_limit_max_requests: int = DEFAULT_RATE_MAX_REQUESTS,
rate_limit_window_s: int = DEFAULT_RATE_WINDOW_S,
rate_limit_lockout_s: int = DEFAULT_RATE_LOCKOUT_S,
max_body_size: int = DEFAULT_MAX_BODY_SIZE,
pre_auth_max_body_size: int = DEFAULT_PRE_AUTH_MAX_BODY_SIZE,
):
self._adapter = adapter
self._port = port
self._host = host
self._path = path.rstrip("/") if path != "/" else path
self._max_body_size = max_body_size
self._pre_auth_max_body_size = pre_auth_max_body_size
self._app: web.Application | None = None
self._runner: web.AppRunner | None = None
self._site: web.TCPSite | None = None
self._rate_limiter = _AuthRateLimiter(
max_requests=rate_limit_max_requests,
window_s=rate_limit_window_s,
lockout_s=rate_limit_lockout_s,
)
@property
def port(self) -> int:
return self._port
@property
def host(self) -> str:
return self._host
@property
def path(self) -> str:
return self._path
@property
def public_url(self) -> str:
host = self._host if self._host != "0.0.0.0" else "localhost"
port_part = f":{self._port}" if self._port not in (80, 443) else ""
return f"http://{host}{port_part}{self._path}"
def rate_limiter_stats(self) -> dict[str, Any]:
return self._rate_limiter.stats()
async def start(self) -> None:
self._app = web.Application()
self._app.router.add_get("/healthz", self._handle_healthz)
self._app.router.add_post(self._path, self._handle_webhook)
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self._host, self._port)
await self._site.start()
logger.info(f"[NextcloudTalk] Webhook server listening on {self._host}:{self._port}{self._path}")
async def stop(self) -> None:
if self._runner:
await self._runner.cleanup()
self._runner = None
self._site = None
self._app = None
logger.info("[NextcloudTalk] Webhook server stopped")
async def _handle_healthz(self, request: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"channel": self._adapter.channel_id,
"rate_limiter": self._rate_limiter.stats(),
}
)
async def _handle_webhook(self, request: web.Request) -> web.Response:
client_ip = request.remote or "unknown"
content_length = request.content_length
if content_length is not None and content_length > self._max_body_size:
logger.warning(f"[NextcloudTalk] Body too large from {client_ip}: {content_length} > {self._max_body_size}")
return web.Response(status=HTTPStatus.PAYLOAD_TOO_LARGE, text="Request body too large")
try:
body = await request.read()
if len(body) > self._max_body_size:
logger.warning(f"[NextcloudTalk] Body oversized from {client_ip}: {len(body)} > {self._max_body_size}")
return web.Response(status=HTTPStatus.PAYLOAD_TOO_LARGE, text="Request body too large")
except Exception:
return web.Response(status=HTTPStatus.BAD_REQUEST, text="Cannot read body")
if len(body) > self._pre_auth_max_body_size:
logger.warning(
f"[NextcloudTalk] Body exceeds pre-auth limit from {client_ip}: "
f"{len(body)} > {self._pre_auth_max_body_size}"
)
return web.Response(status=HTTPStatus.PAYLOAD_TOO_LARGE, text="Request body too large")
headers = {k.lower(): v for k, v in request.headers.items()}
if not self._verify_request(headers, body):
self._rate_limiter.record_failure(client_ip)
return web.Response(status=HTTPStatus.UNAUTHORIZED, text="Signature verification failed")
backend_header = headers.get("x-nextcloud-talk-backend", "")
server_url = self._adapter.config.get("server_url", "")
if (
backend_header
and server_url
and not validate_backend_source(
{"X-Nextcloud-Talk-Backend": backend_header}, server_url, audit_context="nextcloud-talk-webhook"
)
):
logger.warning(f"[NextcloudTalk] Backend source validation failed for {client_ip}")
return web.Response(status=HTTPStatus.FORBIDDEN, text="Backend source mismatch")
try:
payload = json.loads(body)
except json.JSONDecodeError:
return web.Response(status=HTTPStatus.BAD_REQUEST, text="Invalid JSON")
try:
message = normalize_inbound(payload, self._adapter.channel_id)
except Exception as e:
logger.warning(f"[NextcloudTalk] Failed to normalize webhook payload: {e}")
return web.Response(status=HTTPStatus.BAD_REQUEST, text="Invalid payload format")
try:
await self._adapter._handle_message(message)
except Exception as e:
logger.error(f"[NextcloudTalk] Failed to process webhook message: {e}", exc_info=True)
return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR, text="Message processing failed")
return web.Response(status=HTTPStatus.OK, text="OK")
def _verify_request(self, headers: dict[str, str], body: bytes) -> bool:
client_ip = headers.get("x-forwarded-for", headers.get("x-real-ip", "unknown"))
if not self._rate_limiter.check(client_ip):
return False
signature = headers.get("x-nextcloud-talk-sha256", "")
if not signature:
return False
bot_secret = self._adapter.config.get("app_password", self._adapter.config.get("appPassword", ""))
if not bot_secret:
logger.warning("[NextcloudTalk] Cannot verify webhook: no bot_secret configured")
return False
return verify_hmac_signature(body, signature, bot_secret)