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)