230 lines
8.5 KiB
Python
230 lines
8.5 KiB
Python
|
|
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)
|